Web 中的“選區”和“游標”

XboxYan發表於2022-02-24

在 web 開發中,有時不可避免會和“選區”與“游標”打交道,比如選中高亮、選中出現工具欄、手動控制游標位置等。選區就是用滑鼠選中的那一部分,通常是藍色

image-20220224202846466

游標呢,是那個閃爍的豎線嗎?

image-20220224203025183

溫馨提示:文章比較長,耐心看完可以實現完全自主的操作選區和游標

一、“選區”和“游標”是什麼?

先說結論:游標是一種特殊的選區

想搞清楚這個,不得不提到兩個重要的物件:SectionRange。這兩個物件都有大量的屬性和方法,詳細可以檢視官方文件,這裡簡單介紹一下:

  1. Selection 物件表示使用者選擇的文字範圍或插入符號的當前位置。它代表頁面中的文字選區,可能橫跨多個元素。通常由使用者拖拽滑鼠經過文字而產生。
  2. Range物件表示包含節點和部分文字節點的文件片段。通過 selection 物件獲得的 range 物件才是我們操作游標的重點。

獲取 selection ,可以通過全域性的 getSelection 方法

const selection = window.getSelection();

img

通常情況下我們不會直接操作 selection 物件,而是需要操作用 seleciton 物件所對應的使用者選擇的 range 。獲取方式如下:

const range = selection.getRangeAt(0);

img

為什麼這裡 getRangeAt需要傳一個序列呢,難道選區還能有幾個嗎?還真是,只不過目前只有 Firefox 支援多選區,通過cmd鍵(windows 上是 ctrl鍵)可以實現多選區

img

可以看到,此時 selection 返回的 rangeCount為 5。不過大部分情況下都不需要考慮多選區的情況。

如果想獲取選中的文字內容也非常簡單,直接 toString 就可以了

window.getSelection().toString()
// 或者
window.getSelection().getRangeAt(0).toString()

img

再看一個range返回的一個屬性,collapsed,表示選區的起點與終點是否重疊。當collapsedtrue時,選中區域被壓縮成一個點,對於普通的元素,可能什麼都看不到,如果是在可編輯元素上,那這個被壓縮的點就變成了可以閃爍的游標。

img

所以,游標就是一種起始點相同的選區

二、可編輯元素

雖然選區和元素是否可編輯並沒有直接關係,唯一的區別就是,在可編輯元素上可以看到游標,不過很多時候的需求都是針對可編輯元素的。

提到可編輯元素,一般有兩種,一種是預設的表單輸入框 inputtextarea

<input type="text">
<textarea></textarea>

另外一種是給元素新增屬性contenteditable="true",或者 CSS 屬性 -webkit-user-modify

<div contenteditable="true">yux閱文前端</div>

或者

div{
    -webkit-user-modify: read-write;
}

這兩種有什麼區別呢?簡單來說,表單元素更容易控制,瀏覽器提供了更直觀的 API 來操控選區。

三、input 和 textarea 選區操作

首先看這類元素的操作方式,幾乎可以不用 sectionrange 相關 API,可能更好理解一些。API 不太好記,直接看幾個例子吧,這裡以 textarea為例

假設 HTML 如下

<textarea id="txt">閱文旗下囊括 QQ 閱讀、起點中文網、新麗傳媒等業界知名品牌,擁有 1450 萬部作品儲備,940 萬名創作者,覆蓋 200 多種內容品類,觸達數億使用者,已成功輸出包括《慶餘年》《贅婿》《鬼吹燈》《琅琊榜》《全職高手》在內的動畫、影視、遊戲等領域的 IP 改編代表作。</textarea>

1. 主動選中某一區域

表單元素選中區域可以用到 setSelectionRange方法

inputElement.setSelectionRange(selectionStart, selectionEnd [, selectionDirection]);

有 3 個 引數,分別是 selectionStart (起始位置)、 selectionEnd (結束位置)和 selectionDirection(方向)

比如我們想主動選中前兩個字 “閱文”,那麼可以

btn.onclick = () => {
    txt.setSelectionRange(0,2);
    txt.focus();
}

img

如果想全部選中,可以直接用 select 方法

btn.onclick = () => {
    txt.select();
    txt.focus();
}

2. 聚焦到某一位置

如果我們想把游標移動到“閱文”的後面,根據前面所講,游標其實是選區起始位置相同的產物,所以可以這樣

btn.onclick = () => {
    txt.setSelectionRange(2,2); // 設定起始點相同
    txt.focus();
}

img

3. 還原之前的選區

有時候,我們需要在點選其他地方後,再重新選中之前的選區。這就需要先記錄一下之前選區的起始位置,然後主動設定一下就行了

選區的起始位置,可以用 selectionStartselectionEnd這兩個屬性來獲取,所以

const pos = {}
document.onmouseup = (ev) => {
   pos.start = txt.selectionStart;
   pos.end = txt.selectionEnd;
}
btn.onclick = () => {
    txt.setSelectionRange(pos.start,pos.end)
    txt.focus();
}

img

4. 在指定選區插入(替換)內容

表單輸入框插入內容需要用到setRangeText方法,

inputElement.setRangeText(replacement);
inputElement.setRangeText(replacement, start, end [, selectMode]);

這個方法有兩種形式,第2中形式有 4 個引數,第一個引數replacement ,表示需要替換的文字,然後startend是起始位置,預設是該元素當前選中區域,最後一個引數selectMode,表示替換後選區的狀態,有 4 個可選項

  • select 替換後選中
  • start 替換後游標位於替換詞之前
  • end 替換後游標位於替換詞之後
  • preserve 預設值,嘗試保留選區

比如,我們在選區插入或替換成一段文字“❤️❤️❤️”,可以這樣:

btn.onclick = () => {
    txt.setRangeText('❤️❤️❤️')
    txt.focus();
}

img

上面有一個預設值“嘗試保留選區” 是什麼意思呢?假設手動選中的區域是[9,10],如果在[1,2]的位置替換新內容,那麼選區仍然在之前位置。如果在[8,11]的位置替換新內容,由於新內容的位置覆蓋了之前的選區,原選區也就不存在了,那麼替換完之後,選區會選中剛剛插入的新內容

btn.onclick = () => {
    txt.setRangeText('❤️❤️❤️',5,10,'preserve')
    txt.focus();
}

img

以上完整程式碼可以訪問 setSelectionRange & setRangeText (codepen.io),關於表單輸入框的相關操作就到這裡了,下面介紹普通元素的

四、普通元素的選區操作

首先,普通元素並沒有以上方法

img

這就需要用到前面提到的sectionrange相關方法了,這裡 API 也很多,還是從例子看起吧

1. 主動選中某一區域

首先需要主動建立一個Range物件,接著設定區域的起始位置,然後將這個物件新增到Section中就可以了。值得注意的是,設定區域起始位置的方法為 range.setStartrange.setEnd

range.setStart(startNode, startOffset);
range.setEnd(endtNode, endOffset);

為什麼要分成兩部分呢?原因在於普通元素的選區遠比表單要複雜的多! 表單輸入框裡只有單一的文字,普通元素可能會包含多個元素

img

通過兩個方法,可以把這兩者之前的內容區域選中

新增到選區的方法是 selection.addRange

selection.addRange(range)

不過一般在新增之前,應該清除掉之前的選區,可以用 selection.removeAllRanges 方法

selection.removeAllRanges()
selection.addRange(range)

先看純文字的例子,假設 HTML 如下

<div id="txt" contenteditable="true">閱文旗下囊括 QQ 閱讀、起點中文網、新麗傳媒等業界知名品牌,擁有 1450 萬部作品儲備,940 萬名創作者,覆蓋 200 多種內容品類,觸達數億使用者,已成功輸出包括《慶餘年》《贅婿》《鬼吹燈》《琅琊榜》《全職高手》在內的動畫、影視、遊戲等領域的 IP 改編代表作。</div>

如果想將前面兩個字“閱文”選中,可以這樣做

btn.onclick = () => {
  const selection = document.getSelection();
  const range = document.createRange();
  range.setStart(txt.firstChild,0);
  range.setEnd(txt.firstChild,2);
  selection.removeAllRanges();
  selection.addRange(range);
}

img

這裡需要注意一點,在setStartsetEnd中設定的節點是txt.firstChild,而不是txt,這是為什麼呢?

MDN 上是這麼定義的:

如果起始節點型別是 TextComment , or CDATASection 之一, 那麼 startOffset 指的是從起始節點算起字元的偏移量。 對於其他 Node 型別節點, startOffset 是指從起始結點開始算起子節點的偏移量。

什麼意思呢?假設有一個這樣的結構:

<div>yux閱文前端</div>

其實結構是這樣的

img

所以如果將最外層的 div 作為起始節點,那麼對於它本身來說,它只有1個文字節點,如果設定偏移為 2,瀏覽器就直接報錯,由於只有一個文字節點,所以需要以它的第一個文字節點作為起始節點,也就是 firstChild,那樣它就會以每個字元作為偏移量

2. 主動選中富文字中的某一區域

普通元素相比表單元素,最大的區別就是,支援內嵌標籤,也就是富文字,假設這樣一個 HTML

<div id="txt" contenteditable="true">yux<span>閱文</span>前端</div>

真實結構是這樣的

img

我們也可以通過childNodes獲取子節點

div.childNodes

img

如果要選中“閱文”該怎麼做呢?

由於“閱文”是一個獨立的標籤,可以用到另外兩個新的 API,range.selectNoderange.selectNodeContents,這兩個都是表示選中某一節點,不同的是,selectNodeContents僅包含只節點,不包含自身

這裡“閱文”所在的標籤是第2個,所以

btn.onclick = () => {
  const selection = document.getSelection();
  const range = document.createRange();
  range.selectNode(txt.childNodes[1])
  selection.removeAllRanges();
  selection.addRange(range);
}

這裡可以看看 selectNodeContentsselectNode 的具體區別,給 span 新增一個紅色的樣式,下面是selectNode的效果

img

再看selectNodeContents的效果

img

很明顯selectNodeContents只是選中的節點的內部,當刪除後,節點本身還在,所以重新輸入內容還是紅色的。

如果只想選中“閱文”的“閱”字,那如何操作呢?其實就是在這個標籤下往下查詢就行了

btn.onclick = () => {
  const selection = document.getSelection();
  const range = document.createRange();
  range.setStart(txt.childNodes[1].firstChild, 0)
  range.setEnd(txt.childNodes[1].firstChild, 1)
  selection.removeAllRanges();
  selection.addRange(range);
}

img

可以看到,這裡的起始點都是相對於span元素的,而不是外層div的,這似乎有些不合常理?通常我們希望的肯定是針對最外層指定一個區間,比如 [2,5],不管你是什麼結構,直接選中就行了,而不是像這樣手動去找具體的標籤,這該怎麼處理呢?

選區最關鍵的一點就是獲取起始點和結束點以及偏移量,如何通過相對外層的偏移量獲取到最裡層元素的資訊呢?

假設有這樣一段 HTML,稍微有點複雜

<div>yux<span>閱文<strong>前端</strong>團隊</span></div>

試著找了很多官方文件,可惜並沒有直接獲取的 API,只能逐層遍歷了。整體思路就是,先通過childNodes獲取第一層的資訊,被分成好幾個區間,如果需要的偏移量在這個區間,就繼續往裡遍歷,直到最底層,示意如下:

img

只要看紅色部分(#text),不就一目瞭然了?用程式碼實現就是

function getNodeAndOffset(wrap_dom, start=0, end=0){
    const txtList = [];
    const map = function(chlids){
        [...chlids].forEach(el => {
            if (el.nodeName === '#text') {
                txtList.push(el)
            } else {
                map(el.childNodes)
            }
        })
    }
    // 遞迴遍歷,提取出所有 #text
    map(wrap_dom.childNodes);
    // 計算文字的位置區間 [0,3]、[3, 8]、[8,10]
    const clips = txtList.reduce((arr,item,index)=>{
        const end = item.textContent.length + (arr[index-1]?arr[index-1][2]:0)
        arr.push([item, end - item.textContent.length, end])
        return arr
    },[])
    // 查詢滿足條件的範圍區間
    const startNode = clips.find(el => start >= el[1] && start < el[2]);
    const endNode = clips.find(el => end >= el[1] && end < el[2]);
    return [startNode[0], start - startNode[1], endNode[0], end - endNode[1]]
}

有了這個方法,就可以選中任意的區間了,不管是什麼結構

<div id="txt" contenteditable="true">閱文旗下<span>囊括 <span><strong>QQ</strong>閱讀</span>、起點中文網、新麗傳媒等業界知名品牌</span>,擁有 1450 萬部作品儲備,940 萬名<span>創作者</span>,覆蓋 200 多種內容品類,觸達數億使用者,已成功輸出包括《慶餘年》《贅婿》《鬼吹燈》《琅琊榜》《全職高手》在內的動畫、影視、遊戲等領域的 IP 改編代表作。</div>
btn.onclick = () => {
  const selection = document.getSelection();
  const range = document.createRange();
  const nodes = getNodeAndOffset(txt, 7, 12);
  range.setStart(nodes[0], nodes[1])
  range.setEnd(nodes[2], nodes[3])
  selection.removeAllRanges();
  selection.addRange(range);
}

img

3. 聚焦到某一位置

這個就比較容易了,只需要把起始點設定相同就可以了,比如這裡想把游標移動到“QQ”的後面,“QQ”後的位置是“8”,所以可以這樣來實現

btn.onclick = () => {
  const selection = document.getSelection();
  const range = document.createRange();
  const nodes = getNodeAndOffset(txt, 8, 8);
  range.setStart(nodes[0], nodes[1])
  range.setEnd(nodes[2], nodes[3])
  selection.removeAllRanges();
  selection.addRange(range);
}

img

4. 還原之前的選區

這個有兩種方式,第一種,可以先把之前的選區存下來,然後後面復原就行了

let lastRange = null;
txt.onkeyup = function (e) {
    var selection = document.getSelection()
    // 儲存最後的range物件
    lastRange = selection.getRangeAt(0)
}
btn.onclick = () => {
  const selection = document.getSelection();
  selection.removeAllRanges();
  // 還原上次的選區
  selection.addRange(lastRange);
}

img

但是這種方式不太靠譜,存下來的lastRange很容易丟失,因為這個是跟隨內容的,如果內容發生了改變,這個選區也就不存在了,所以需要一種更靠譜的方式,比如記錄之前的絕對偏移量,同樣需要之前的遍歷,找到最底層文字節點,然後計算出相對整段文字的偏移量,程式碼如下:

function getRangeOffset(wrap_dom){
    const txtList = [];
    const map = function(chlids){
        [...chlids].forEach(el => {
            if (el.nodeName === '#text') {
                txtList.push(el)
            } else {
                map(el.childNodes)
            }
        })
    }
    // 遞迴遍歷,提取出所有 #text
    map(wrap_dom.childNodes);
    // 計算文字的位置區間 [0,3]、[3, 8]、[8,10]
    const clips = txtList.reduce((arr,item,index)=>{
        const end = item.textContent.length + (arr[index-1]?arr[index-1][2]:0)
        arr.push([item, end - item.textContent.length, end])
        return arr
    },[])
    const range = window.getSelection().getRangeAt(0);
    // 匹配選區與區間的#text,計算出整體偏移量
    const startOffset = (clips.find(el => range.startContainer === el[0]))[1] + range.startOffset;
    const endOffset = (clips.find(el => range.endContainer === el[0]))[1] + range.endOffset;
    return [startOffset, endOffset]
}

img

然後就可以利用這個偏移量,就主動選中該區域了

const pos= {}
txt.onmouseup = function (e) {
    const offset = getRangeOffset(txt)
    pos.start = offset[0]
    pos.end = offset[1]
}
btn.onclick = () => {
  const selection = document.getSelection();
  const range = document.createRange();
  const nodes = getNodeAndOffset(txt, pos.start, pos.end);
  range.setStart(nodes[0], nodes[1])
  range.setEnd(nodes[2], nodes[3])
  selection.removeAllRanges();
  selection.addRange(range);
}

img

5. 在指定選區插入(替換)內容

在選區插入內容,可以用到 range.insertNode 方法,它表示在選區的起點處插入一個節點,並不會替換掉當前已經選中的,如果要替換,可以先刪除,刪除需要用到 deleteContents 方法,具體實現就是

let lastRange = null;
txt.onmouseup = function (e) {
    lastRange = window.getSelection().getRangeAt(0);
}
btn.onclick = () => {
  const newNode = document.createTextNode('我是新內容')
  lastRange.deleteContents()
  lastRange.insertNode(newNode)
}

這裡需要注意的是,必須是一個節點,如果是文字,可以用 document.createTextNode 來建立

img

還可以插入帶標籤的內容

btn.onclick = () => {
  const newNode = document.createElement('mark');
  newNode.textContent = '我是新內容' 
  lastRange.deleteContents()
  lastRange.insertNode(newNode)
}

img

插入的新內容預設是選中的,如果希望插入後游標在新內容後邊,怎麼處理呢

這時可以用到 range.setStartAfter 方法,表示設定區間的起點為該元素的後面,終點預設就是該元素的後面,不用處理,實現就是

btn.onclick = () => {
  const newNode = document.createElement('mark');
  newNode.textContent = '我是新內容' 
  lastRange.deleteContents()
  lastRange.insertNode(newNode)
  lastRange.setStartAfter(newNode)
  txt.focus()
}

img

6. 給指定選區包裹標籤

最後再來看一個比較常見的例子,在選中時將所選區域包裹一層標籤。

這個是有官方 API 支援的,需要用到 range.surroundContents 方法,表示給選區包裹一層標籤

btn.onclick = () => {
  const mark = document.createElement('mark');
  lastRange.surroundContents(mark)
}

img

但是,這個方法有一個缺陷,當選區有“斷層”時,比如這種情況,就會直接報錯

img

這裡可以用另一種方式,能夠規避這個問題,和上面替換內容原理類似,不過需要先獲取選區內容,獲取選區內容可以通過 range.extractContents 方法,該方法返回的是一個 DocumentFragment 物件,將選區內容新增到新節點上,然後插入新內容,具體實現如下

btn.onclick = () => {
    const mark = document.createElement('mark');
  // 記錄選區內容
  mark.append(lastRange.extractContents())
  lastRange.insertNode(mark) 
}

img

以上完整程式碼可以訪問 Section & Range (codepen.io)

五、用兩張圖總結一下

如果完全掌握這些方法,相信對選區的處理可以遊刃有餘,記住一點,游標是一種特殊的選區,並且跟元素是否聚焦沒什麼關係,然後就是各種 API 了,這裡用兩張圖列了一下大致關係

img

img

隨著 vue 、react 這些框架的流行,這些原生的 API 可能會很少有人提及,大部分的功能框架都幫我們做了封裝,但總有一些功能是不滿足的,這就必須要藉助“原生的力量”了。最後,如果覺得還不錯,對你有幫助的話,歡迎點贊、收藏、轉發❤❤❤

相關文章