在 web 開發中,有時不可避免會和“選區”與“游標”打交道,比如選中高亮、選中出現工具欄、手動控制游標位置等。選區就是用滑鼠選中的那一部分,通常是藍色
游標呢,是那個閃爍的豎線嗎?
溫馨提示:文章比較長,耐心看完可以實現完全自主的操作選區和游標
一、“選區”和“游標”是什麼?
先說結論:游標是一種特殊的選區。
想搞清楚這個,不得不提到兩個重要的物件:Selection 和 Range。這兩個物件都有大量的屬性和方法,詳細可以檢視官方文件,這裡簡單介紹一下:
- Selection 物件表示使用者選擇的文字範圍或插入符號的當前位置。它代表頁面中的文字選區,可能橫跨多個元素。通常由使用者拖拽滑鼠經過文字而產生。
- Range物件表示包含節點和部分文字節點的文件片段。通過
selection
物件獲得的range
物件才是我們操作游標的重點。
獲取 selection
,可以通過全域性的 getSelection 方法
const selection = window.getSelection();
通常情況下我們不會直接操作 selection
物件,而是需要操作用 seleciton
物件所對應的使用者選擇的 range
。獲取方式如下:
const range = selection.getRangeAt(0);
為什麼這裡 getRangeAt
需要傳一個序列呢,難道選區還能有幾個嗎?還真是,只不過目前只有 Firefox 支援多選區,通過cmd
鍵(windows 上是 ctrl
鍵)可以實現多選區
可以看到,此時 selection
返回的 rangeCount
為 5。不過大部分情況下都不需要考慮多選區的情況。
如果想獲取選中的文字內容也非常簡單,直接 toString
就可以了
window.getSelection().toString()
// 或者
window.getSelection().getRangeAt(0).toString()
再看一個range
返回的一個屬性,collapsed
,表示選區的起點與終點是否重疊。當collapsed
為true
時,選中區域被壓縮成一個點,對於普通的元素,可能什麼都看不到,如果是在可編輯元素上,那這個被壓縮的點就變成了可以閃爍的游標。
所以,游標就是一種起始點相同的選區
二、可編輯元素
雖然選區和元素是否可編輯並沒有直接關係,唯一的區別就是,在可編輯元素上可以看到游標,不過很多時候的需求都是針對可編輯元素的。
提到可編輯元素,一般有兩種,一種是預設的表單輸入框 input
和textarea
<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 選區操作
首先看這類元素的操作方式,幾乎可以不用 section
和 range
相關 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();
}
如果想全部選中,可以直接用 select
方法
btn.onclick = () => {
txt.select();
txt.focus();
}
2. 聚焦到某一位置
如果我們想把游標移動到“閱文”的後面,根據前面所講,游標其實是選區起始位置相同的產物,所以可以這樣
btn.onclick = () => {
txt.setSelectionRange(2,2); // 設定起始點相同
txt.focus();
}
3. 還原之前的選區
有時候,我們需要在點選其他地方後,再重新選中之前的選區。這就需要先記錄一下之前選區的起始位置,然後主動設定一下就行了
選區的起始位置,可以用 selectionStart
和selectionEnd
這兩個屬性來獲取,所以
const pos = {}
document.onmouseup = (ev) => {
pos.start = txt.selectionStart;
pos.end = txt.selectionEnd;
}
btn.onclick = () => {
txt.setSelectionRange(pos.start,pos.end)
txt.focus();
}
4. 在指定選區插入(替換)內容
表單輸入框插入內容需要用到setRangeText方法,
inputElement.setRangeText(replacement);
inputElement.setRangeText(replacement, start, end [, selectMode]);
這個方法有兩種形式,第2中形式有 4 個引數,第一個引數replacement
,表示需要替換的文字,然後start
和end
是起始位置,預設是該元素當前選中區域,最後一個引數selectMode
,表示替換後選區的狀態,有 4 個可選項
- select 替換後選中
- start 替換後游標位於替換詞之前
- end 替換後游標位於替換詞之後
- preserve 預設值,嘗試保留選區
比如,我們在選區插入或替換成一段文字“❤️❤️❤️”,可以這樣:
btn.onclick = () => {
txt.setRangeText('❤️❤️❤️')
txt.focus();
}
上面有一個預設值“嘗試保留選區” 是什麼意思呢?假設手動選中的區域是[9,10]
,如果在[1,2]
的位置替換新內容,那麼選區仍然在之前位置。如果在[8,11]
的位置替換新內容,由於新內容的位置覆蓋了之前的選區,原選區也就不存在了,那麼替換完之後,選區會選中剛剛插入的新內容
btn.onclick = () => {
txt.setRangeText('❤️❤️❤️',5,10,'preserve')
txt.focus();
}
以上完整程式碼可以訪問 setSelectionRange & setRangeText (codepen.io),關於表單輸入框的相關操作就到這裡了,下面介紹普通元素的
四、普通元素的選區操作
首先,普通元素並沒有以上方法
這就需要用到前面提到的section
和range
相關方法了,這裡 API 也很多,還是從例子看起吧
1. 主動選中某一區域
首先需要主動建立一個Range
物件,接著設定區域的起始位置,然後將這個物件新增到Section
中就可以了。值得注意的是,設定區域起始位置的方法為 range.setStart 和 range.setEnd
range.setStart(startNode, startOffset);
range.setEnd(endtNode, endOffset);
為什麼要分成兩部分呢?原因在於普通元素的選區遠比表單要複雜的多! 表單輸入框裡只有單一的文字,普通元素可能會包含多個元素
通過兩個方法,可以把這兩者之前的內容區域選中
新增到選區的方法是 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);
}
這裡需要注意一點,在setStart
和setEnd
中設定的節點是txt.firstChild
,而不是txt
,這是為什麼呢?
MDN 上是這麼定義的:
如果起始節點型別是Text
,Comment
, orCDATASection
之一, 那麼startOffset
指的是從起始節點算起字元的偏移量。 對於其他Node
型別節點,startOffset
是指從起始結點開始算起子節點的偏移量。
什麼意思呢?假設有一個這樣的結構:
<div>yux閱文前端</div>
其實結構是這樣的
所以如果將最外層的 div
作為起始節點,那麼對於它本身來說,它只有1個文字節點,如果設定偏移為 2,瀏覽器就直接報錯,由於只有一個文字節點,所以需要以它的第一個文字節點作為起始節點,也就是 firstChild
,那樣它就會以每個字元作為偏移量
2. 主動選中富文字中的某一區域
普通元素相比表單元素,最大的區別就是,支援內嵌標籤,也就是富文字,假設這樣一個 HTML
<div id="txt" contenteditable="true">yux<span>閱文</span>前端</div>
真實結構是這樣的
我們也可以通過childNodes
獲取子節點
div.childNodes
如果要選中“閱文”該怎麼做呢?
由於“閱文”是一個獨立的標籤,可以用到另外兩個新的 API,range.selectNode 和 range.selectNodeContents,這兩個都是表示選中某一節點,不同的是,selectNodeContents
僅包含只節點,不包含自身
這裡“閱文”所在的標籤是第2個,所以
btn.onclick = () => {
const selection = document.getSelection();
const range = document.createRange();
range.selectNode(txt.childNodes[1])
selection.removeAllRanges();
selection.addRange(range);
}
這裡可以看看 selectNodeContents
和 selectNode
的具體區別,給 span
新增一個紅色的樣式,下面是selectNode
的效果
再看selectNodeContents
的效果
很明顯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);
}
可以看到,這裡的起始點都是相對於span
元素的,而不是外層div
的,這似乎有些不合常理?通常我們希望的肯定是針對最外層指定一個區間,比如 [2,5]
,不管你是什麼結構,直接選中就行了,而不是像這樣手動去找具體的標籤,這該怎麼處理呢?
選區最關鍵的一點就是獲取起始點和結束點以及偏移量,如何通過相對外層的偏移量獲取到最裡層元素的資訊呢?
假設有這樣一段 HTML,稍微有點複雜
<div>yux<span>閱文<strong>前端</strong>團隊</span></div>
試著找了很多官方文件,可惜並沒有直接獲取的 API,只能逐層遍歷了。整體思路就是,先通過childNodes
獲取第一層的資訊,被分成好幾個區間,如果需要的偏移量在這個區間,就繼續往裡遍歷,直到最底層,示意如下:
只要看紅色部分(#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);
}
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);
}
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);
}
但是這種方式不太靠譜,存下來的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]
}
然後就可以利用這個偏移量,就主動選中該區域了
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);
}
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
來建立
還可以插入帶標籤的內容
btn.onclick = () => {
const newNode = document.createElement('mark');
newNode.textContent = '我是新內容'
lastRange.deleteContents()
lastRange.insertNode(newNode)
}
插入的新內容預設是選中的,如果希望插入後游標在新內容後邊,怎麼處理呢
這時可以用到 range.setStartAfter 方法,表示設定區間的起點為該元素的後面,終點預設就是該元素的後面,不用處理,實現就是
btn.onclick = () => {
const newNode = document.createElement('mark');
newNode.textContent = '我是新內容'
lastRange.deleteContents()
lastRange.insertNode(newNode)
lastRange.setStartAfter(newNode)
txt.focus()
}
6. 給指定選區包裹標籤
最後再來看一個比較常見的例子,在選中時將所選區域包裹一層標籤。
這個是有官方 API 支援的,需要用到 range.surroundContents 方法,表示給選區包裹一層標籤
btn.onclick = () => {
const mark = document.createElement('mark');
lastRange.surroundContents(mark)
}
但是,這個方法有一個缺陷,當選區有“斷層”時,比如這種情況,就會直接報錯
這裡可以用另一種方式,能夠規避這個問題,和上面替換內容原理類似,不過需要先獲取選區內容,獲取選區內容可以通過 range.extractContents 方法,該方法返回的是一個 DocumentFragment 物件,將選區內容新增到新節點上,然後插入新內容,具體實現如下
btn.onclick = () => {
const mark = document.createElement('mark');
// 記錄選區內容
mark.append(lastRange.extractContents())
lastRange.insertNode(mark)
}
以上完整程式碼可以訪問 Section & Range (codepen.io)
五、用兩張圖總結一下
如果完全掌握這些方法,相信對選區的處理可以遊刃有餘,記住一點,游標是一種特殊的選區,並且跟元素是否聚焦沒什麼關係,然後就是各種 API 了,這裡用兩張圖列了一下大致關係
以上 API 並不全面,但覆蓋了平時開發中了絕大部分場景,如果想了解更全面的屬性和方法,可以上 MDN 檢視。
隨著 vue 、react 這些框架的流行,這些原生的 API 可能會很少有人提及,大部分的功能框架都幫我們做了封裝,但總有一些功能是不滿足的,這就必須要藉助“原生的力量”了。最後,如果覺得還不錯,對你有幫助的話,歡迎點贊、收藏、轉發❤❤❤