在本章中,我們將介紹文檔中的選擇以及在表單字段(如 ?<input>
?)中的選擇。
JavaScript 可以訪問現(xiàn)有的選擇,選擇/取消全部或部分 DOM 節(jié)點的選擇,從文檔中刪除所選部分,將其包裝到一個標簽(tag)中,等。
你可以在本章末尾的“總結(jié)”部分找到一些常見的使用方式??赡芫鸵呀?jīng)滿足了你當前的需求,但如果你閱讀全文,將會有更多收獲。
底層的(underlying)Range
和 Selection
對象很容易掌握,因此,你不需要任何訣竅便可以使用它們做你想要做的事兒。
選擇的基本概念是 Range:本質(zhì)上是一對“邊界點”:范圍起點和范圍終點。
在沒有任何參數(shù)的情況下,創(chuàng)建一個 Range
對象:
let range = new Range();
然后,我們可以使用 range.setStart(node, offset)
和 range.setEnd(node, offset)
來設(shè)置選擇邊界。
正如你可能猜到的那樣,我們將進一步使用 Range
對象進行選擇,但首先讓我們創(chuàng)建一些這樣的對象。
有趣的是,這兩種方法中的第一個參數(shù) node
都可以是文本節(jié)點或元素節(jié)點,而第二個參數(shù)的含義依賴于此。
如果 node
是一個文本節(jié)點,那么 offset
則必須是其文本中的位置。
例如,對于給定的 <p>Hello</p>
,我們可以像下面這樣創(chuàng)建一個包含字母 “l(fā)l” 的范圍:
<p id="p">Hello</p>
<script>
let range = new Range();
range.setStart(p.firstChild, 2);
range.setEnd(p.firstChild, 4);
// 對 range 進行 toString 處理,range 則會把其包含的內(nèi)容以文本的形式返回
console.log(range); // ll
</script>
在這里,我們獲取 <p>
的第一個子節(jié)點(即文本節(jié)點)并指定其中的文本位置:
或者,如果 node
是一個元素節(jié)點,那么 offset
則必須是子元素的編號。
這對于創(chuàng)建包含整個節(jié)點的范圍很方便,而不是在其文本中的某處停止。
例如,我們有一個更復(fù)雜的文檔片段:
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
這是它的 DOM 結(jié)構(gòu),包含元素和文本節(jié)點:
讓我們?yōu)?nbsp;"Example: <i>italic</i>"
設(shè)置一個范圍。
正如我們所看到的,這個短語正好由 <p>
的索引為 0
和 1
的兩個子元素組成。
<p>
作為父節(jié)點 node
,0
作為偏移量。因此,我們可以將其設(shè)置為 range.setStart(p, 0)
。
<p>
作為父節(jié)點 node
,但以 2
作為偏移量(它指定最大范圍,但不包括 offset
)。因此,我們可以將其設(shè)置為 range.setEnd(p, 2)
。
示例如下,如果你運行它,你可以看到文本被選中:
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
<script>
let range = new Range();
range.setStart(p, 0);
range.setEnd(p, 2);
// 范圍的 toString 以文本形式返回其內(nèi)容,不帶標簽
console.log(range); // Example: italic
// 將此范圍應(yīng)用于文檔選擇,后文有解釋
document.getSelection().addRange(range);
</script>
這是一個更靈活的測試臺,你可以在其中設(shè)置范圍開始/結(jié)束編號,并探索各種情況:
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
From <input id="start" type="number" value=1> – To <input id="end" type="number" value=4>
<button id="button">Click to select</button>
<script>
button.onclick = () => {
let range = new Range();
range.setStart(p, start.value);
range.setEnd(p, end.value);
// 應(yīng)用選擇,后文有解釋
document.getSelection().removeAllRanges();
document.getSelection().addRange(range);
};
</script>
例如,在同一個 <p>
中從偏移量 1
到 4
選擇得到的范圍為 <i>italic</i> and <b>bold</b>
:
起始和結(jié)束的節(jié)點可以不同
我們不是必須在
setStart
和setEnd
中使用相同的節(jié)點。一個范圍可能會跨越很多不相關(guān)的節(jié)點。唯一要注意的是終點要在起點之后。
讓我們在示例中選擇一個更大的片段,像這樣:
我們已經(jīng)知道如何實現(xiàn)它了。我們只需要將起點和終點設(shè)置為文本節(jié)點中的相對偏移量即可。
我們需要創(chuàng)建一個范圍,它:
<p>
? 的第一個子節(jié)點的位置 2 開始(選擇 "Example: " 中除前兩個字母外的所有字母)<b>
? 的第一個子節(jié)點的位置 3 結(jié)束(選擇 “bold” 的前三個字母,就這些):<p id="p">Example: <i>italic</i> and <b>bold</b></p>
<script>
let range = new Range();
range.setStart(p.firstChild, 2);
range.setEnd(p.querySelector('b').firstChild, 3);
console.log(range); // ample: italic and bol
// 使用此范圍進行選擇(后文有解釋)
window.getSelection().addRange(range);
</script>
正如你所看到的,選擇我們想要的范圍其實很容易實現(xiàn)。
如果我們想將節(jié)點作為一個整體,我們可以將元素傳入 setStart/setEnd
。否則,我們可以在文本層級上進行操作。
我們在上面的示例中創(chuàng)建的 range
對象具有以下屬性:
startContainer
?,?startOffset
? —— 起始節(jié)點和偏移量,<p>
? 中的第一個文本節(jié)點和 ?2
?。endContainer
?,?endOffset
? —— 結(jié)束節(jié)點和偏移量,<b>
? 中的第一個文本節(jié)點和 ?3
?。collapsed
? —— 布爾值,如果范圍在同一點上開始和結(jié)束(所以范圍內(nèi)沒有內(nèi)容)則為 ?true
?,false
?commonAncestorContainer
? —— 在范圍內(nèi)的所有節(jié)點中最近的共同祖先節(jié)點,<p>
?有許多便利的方法可以操縱范圍。
我們已經(jīng)見過了 setStart
和 setEnd
,這還有其他類似的方法。
設(shè)置范圍的起點:
setStart(node, offset)
? 將起點設(shè)置在:?node
? 中的位置 ?offset
?setStartBefore(node)
? 將起點設(shè)置在:?node
? 前面setStartAfter(node)
? 將起點設(shè)置在:?node
? 后面設(shè)置范圍的終點(類似的方法):
setEnd(node, offset)
? 將終點設(shè)置為:?node
? 中的位置 ?offset
?setEndBefore(node)
? 將終點設(shè)置為:?node
? 前面setEndAfter(node)
? 將終點設(shè)置為:?node
? 后面從技術(shù)上講,setStart/setEnd
可以做任何事,但是更多的方法提供了更多的便捷性。
在所有這些方法中,node
都可以是文本或者元素節(jié)點:對于文本節(jié)點,偏移量 offset
跨越的是很多字母,而對于元素節(jié)點則跨越的是很多子節(jié)點。
更多創(chuàng)建范圍的方法:
selectNode(node)
? 設(shè)置范圍以選擇整個 ?node
?selectNodeContents(node)
? 設(shè)置范圍以選擇整個 ?node
? 的內(nèi)容collapse(toStart)
? 如果 ?toStart=true
? 則設(shè)置 end=start,否則設(shè)置 start=end,從而折疊范圍cloneRange()
? 創(chuàng)建一個具有相同起點/終點的新范圍創(chuàng)建范圍后,我們可以使用以下方法操作其內(nèi)容:
deleteContents()
? —— 從文檔中刪除范圍中的內(nèi)容extractContents()
? —— 從文檔中刪除范圍中的內(nèi)容,并將刪除的內(nèi)容作為 DocumentFragment 返回cloneContents()
? —— 復(fù)制范圍中的內(nèi)容,并將復(fù)制的內(nèi)容作為 DocumentFragment 返回insertNode(node)
? —— 在范圍的起始處將 ?node
? 插入文檔surroundContents(node)
? —— 使用 ?node
? 將所選范圍中的內(nèi)容包裹起來。要使此操作有效,則該范圍必須包含其中所有元素的開始和結(jié)束標簽:不能像 ?<i>abc
? 這樣的部分范圍。使用這些方法,我們基本上可以對選定的節(jié)點執(zhí)行任何操作。
點擊按鈕運行所選內(nèi)容上的方法,點擊 "resetExample" 進行重置。
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
<p id="result"></p>
<script>
let range = new Range();
// 下面演示了上述的每個方法:
let methods = {
deleteContents() {
range.deleteContents()
},
extractContents() {
let content = range.extractContents();
result.innerHTML = "";
result.append("extracted: ", content);
},
cloneContents() {
let content = range.cloneContents();
result.innerHTML = "";
result.append("cloned: ", content);
},
insertNode() {
let newNode = document.createElement('u');
newNode.innerHTML = "NEW NODE";
range.insertNode(newNode);
},
surroundContents() {
let newNode = document.createElement('u');
try {
range.surroundContents(newNode);
} catch(e) { console.log(e) }
},
resetExample() {
p.innerHTML = `Example: <i>italic</i> and <b>bold</b>`;
result.innerHTML = "";
range.setStart(p.firstChild, 2);
range.setEnd(p.querySelector('b').firstChild, 3);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
};
for(let method in methods) {
document.write(`<div><button onclick="methods.${method}()">${method}</button></div>`);
}
methods.resetExample();
</script>
還有比較范圍的方法,但是很少使用。當你需要它們時,請參考 規(guī)范 或 MDN 手冊。
Range
是用于管理選擇范圍的通用對象。盡管創(chuàng)建一個 Range
并不意味著我們可以在屏幕上看到一個內(nèi)容選擇。
我們可以創(chuàng)建 Range
對象并傳遞它們 —— 但它們并不會在視覺上選擇任何內(nèi)容。
文檔選擇是由 Selection
對象表示的,可通過 window.getSelection()
或 document.getSelection()
來獲取。一個選擇可以包括零個或多個范圍。至少,Selection API 規(guī)范 是這么說的。不過實際上,只有
Firefox 允許使用 Ctrl+click (Mac 上用 Cmd+click) 在文檔中選擇多個范圍。
這是在 Firefox 中做的一個具有 3 個范圍的選擇的截圖:
其他瀏覽器最多支持 1 個范圍。正如我們將看到的,某些 Selection
方法暗示可能有多個范圍,但同樣,在除 Firefox 之外的所有瀏覽器中,范圍最多是 1。
如前所述,理論上一個選擇可能包含多個范圍。我們可以使用下面這個方法獲取這些范圍對象:
getRangeAt(i)
? —— 獲取第 ?i
? 個范圍,?i
? 從 ?0
? 開始。在除 Firefox 之外的所有瀏覽器中,僅使用 ?0
?。此外,還有更方便的屬性。
與范圍類似,選擇的起點被稱為“錨點(anchor)”,終點被稱為“焦點(focus)”。
主要的選擇屬性有:
anchorNode
? —— 選擇的起始節(jié)點,anchorOffset
? —— 選擇開始的 ?anchorNode
? 中的偏移量,focusNode
? —— 選擇的結(jié)束節(jié)點,focusOffset
? —— 選擇開始處 ?focusNode
? 的偏移量,isCollapsed
? —— 如果未選擇任何內(nèi)容(空范圍)或不存在,則為 ?true
?。rangeCount
? —— 選擇中的范圍數(shù),除 Firefox 外,其他瀏覽器最多為 ?1
?。選擇和范圍的起點和終點對比
選擇(selection)的錨點/焦點和
Range
的起點和終點有一個很重要的區(qū)別。
正如我們所知道的,
Range
對象的起點必須在其終點之前。
但對于選擇,并不總是這樣的。
我們可以在兩個方向上使用鼠標進行選擇:“從左到右”或“從右到左”。
換句話說,當按下鼠標按鍵,然后它在文檔中向前移動時,它結(jié)束的位置(焦點)將在它開始的位置(錨點)之后。
例如,如果用戶使用鼠標從 “Example” 開始選擇到 “italic”:
![]()
但是,我們也可以從前向后進行相同的選擇:從 “italic” 到 “Example”(從前向后),這樣它結(jié)束的位置(焦點)將在它開始的位置(錨點)之前。
![]()
有一些事件可以跟蹤選擇:
elem.onselectstart
? —— 當在元素 ?elem
? 上(或在其內(nèi)部)開始選擇時。例如,當用戶在元素 ?elem
? 上按下鼠標按鍵并開始移動指針時。document.onselectionchange
? —— 當選擇發(fā)生變化或開始時。document
? 上設(shè)置。它跟蹤的是 ?document
? 中的所有選擇。下面是一個小例子,它跟蹤了 document
上當前的選擇,并將選擇邊界顯示出來:
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
From <input id="from" disabled> – To <input id="to" disabled>
<script>
document.onselectionchange = function() {
let selection = document.getSelection();
let {anchorNode, anchorOffset, focusNode, focusOffset} = selection;
// anchorNode 和 focusNode 通常是文本節(jié)點
from.value = `${anchorNode?.data}, offset ${anchorOffset}`;
to.value = `${focusNode?.data}, offset ${focusOffset}`;
};
</script>
復(fù)制所選內(nèi)容有兩種方式:
document.getSelection().toString()
? 來獲取其文本形式。getRangeAt(...)
? 獲取底層的(underlying)范圍。?Range
? 對象還具有 ?cloneContents()
? 方法,該方法會拷貝范圍中的內(nèi)容并以 ?DocumentFragment
? 的形式返回,我們可以將這個返回值插入到其他位置。下面是將所選內(nèi)容復(fù)制為文本和 DOM 節(jié)點的演示:
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
Cloned: <span id="cloned"></span>
<br>
As text: <span id="astext"></span>
<script>
document.onselectionchange = function() {
let selection = document.getSelection();
cloned.innerHTML = astext.innerHTML = "";
// 從范圍復(fù)制 DOM 節(jié)點(這里我們支持多選)
for (let i = 0; i < selection.rangeCount; i++) {
cloned.append(selection.getRangeAt(i).cloneContents());
}
// 獲取為文本形式
astext.innerHTML += selection;
};
</script>
我們可以通過添加/移除范圍來處理選擇:
getRangeAt(i)
? —— 獲取從 ?0
? 開始的第 i 個范圍。在除 Firefox 之外的所有瀏覽器中,僅使用 ?0
?。addRange(range)
? —— 將 ?range
? 添加到選擇中。如果選擇已有關(guān)聯(lián)的范圍,則除 Firefox 外的所有瀏覽器都將忽略該調(diào)用。removeRange(range)
? —— 從選擇中刪除 ?range
?。removeAllRanges()
? —— 刪除所有范圍。empty()
? —— ?removeAllRanges
? 的別名。還有一些方便的方法可以直接操作選擇范圍,而無需中間的 Range
調(diào)用:
collapse(node, offset)
? —— 用一個新的范圍替換選定的范圍,該新范圍從給定的 ?node
? 處開始,到偏移 ?offset
? 處結(jié)束。setPosition(node, offset)
? —— ?collapse
? 的別名。collapseToStart()
? —— 折疊(替換為空范圍)到選擇起點,collapseToEnd()
? —— 折疊到選擇終點,extend(node, offset)
? —— 將選擇的焦點(focus)移到給定的 ?node
?,位置偏移 ?oofset
?,setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset)
? —— 用給定的起點 ?anchorNode/anchorOffset
? 和終點 ?focusNode/focusOffset
? 來替換選擇范圍。選中它們之間的所有內(nèi)容。selectAllChildren(node)
? —— 選擇 ?node
? 的所有子節(jié)點。deleteFromDocument()
? —— 從文檔中刪除所選擇的內(nèi)容。containsNode(node, allowPartialContainment = false)
? —— 檢查選擇中是否包含 ?node
?(若第二個參數(shù)是 ?true
?,則只需包含 ?node
? 的部分內(nèi)容即可)對于大多數(shù)需求,這些方法就夠了,無需訪問底層的(underlying)Range
對象。
例如,選擇段落 <p>
的全部內(nèi)容:
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
<script>
// 從 <p> 的第 0 個子節(jié)點選擇到最后一個子節(jié)點
document.getSelection().setBaseAndExtent(p, 0, p, p.childNodes.length);
</script>
使用范圍來完成同一個操作:
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
<script>
let range = new Range();
range.selectNodeContents(p); // 或者也可以使用 selectNode(p) 來選擇 <p> 標簽
document.getSelection().removeAllRanges(); // 清除現(xiàn)有選擇(如果有的話)
document.getSelection().addRange(range);
</script>
如要選擇一些內(nèi)容,請先移除現(xiàn)有的選擇
如果在文檔中已存在選擇,則首先使用
removeAllRanges()
將其清空。然后添加范圍。否則,除 Firefox 外的所有瀏覽器都將忽略新范圍。
某些選擇方法例外,它們會替換現(xiàn)有的選擇,例如
setBaseAndExtent
。
諸如 input
和 textarea
等表單元素提供了 專用的選擇 API,沒有 Selection
或 Range
對象。由于輸入值是純文本而不是
HTML,因此不需要此類對象,一切都變得更加簡單。
屬性:
input.selectionStart
? —— 選擇的起始位置(可寫),input.selectionEnd
? —— 選擇的結(jié)束位置(可寫),input.selectionDirection
? —— 選擇方向,其中之一:“forward”,“backward” 或 “none”(例如使用鼠標雙擊進行的選擇),事件:
input.onselect
? —— 當某個東西被選擇時觸發(fā)。方法:
input.select()
? —— 選擇文本控件中的所有內(nèi)容(可以是 ?textarea
? 而不是 ?input
?),input.setSelectionRange(start, end, [direction])
? —— 在給定方向上(可選),從 ?start
? 一直選擇到 ?end
?。input.setRangeText(replacement, [start], [end], [selectionMode])
? —— 用新文本替換范圍中的文本。可選參數(shù) start
和 end
,如果提供的話,則設(shè)置范圍的起點和終點,否則使用用戶的選擇。
最后一個參數(shù) selectionMode
決定替換文本后如何設(shè)置選擇??赡艿闹禐椋?
"select"
? —— 將選擇新插入的文本。"start"
? —— 選擇范圍將在插入的文本之前折疊(光標將在其之前)。"end"
? —— 選擇范圍將在插入的文本之后折疊(光標將緊隨其后)。"preserve"
? —— 嘗試保留選擇。這是默認值。現(xiàn)在,讓我們看看這些方法的實際使用。
例如,此段代碼使用 onselect
事件來跟蹤選擇:
<textarea id="area" style="width:80%;height:60px">
Selecting in this text updates values below.
</textarea>
<br>
From <input id="from" disabled> – To <input id="to" disabled>
<script>
area.onselect = function() {
from.value = area.selectionStart;
to.value = area.selectionEnd;
};
</script>
請注意:
onselect
? 是在某項被選擇時觸發(fā),而在選擇被刪除時不觸發(fā)。document.onselectionchange
? 事件,因為它與 ?document
? 選擇和范圍不相關(guān)。一些瀏覽器會生成它,但我們不應(yīng)該依賴它。我們可以更改 selectionStart
和 selectionEnd
,二者設(shè)定了選擇。
一個重要的邊界情況是 selectionStart
和 selectionEnd
彼此相等。那正是光標位置?;蛘?,換句話說,當未選擇任何內(nèi)容時,選擇會折疊在光標位置。
因此,通過將 selectionStart
和 selectionEnd
設(shè)置為相同的值,我們可以移動光標。
例如:
<textarea id="area" style="width:80%;height:60px">
Focus on me, the cursor will be at position 10.
</textarea>
<script>
area.onfocus = () => {
// 設(shè)置零延遲 setTimeout 以在瀏覽器 "focus" 行為完成后運行
setTimeout(() => {
// 我們可以設(shè)置任何選擇
// 如果 start=end,則光標就會在該位置
area.selectionStart = area.selectionEnd = 10;
});
};
</script>
如要修改選擇的內(nèi)容,我們可以使用 input.setRangeText()
方法。當然,我們可以讀取 selectionStart/End
,并在了解選擇的情況下更改 value
的相應(yīng)子字符串,但是 setRangeText
功能更強大,通常更方便。
那是一個有點復(fù)雜的方法。使用其最簡單的單參數(shù)形式,它可以替換用戶選擇的范圍并刪除該選擇。
例如,這里的用戶的選擇將被包裝在 *...*
中:
<input id="input" style="width:200px" value="Select here and click the button">
<button id="button">Wrap selection in stars *...*</button>
<script>
button.onclick = () => {
if (input.selectionStart == input.selectionEnd) {
return; // 什么都沒選
}
let selected = input.value.slice(input.selectionStart, input.selectionEnd);
input.setRangeText(`*${selected}*`);
};
</script>
使用更多參數(shù),我們可以設(shè)置范圍 start
和 end
。
在下面這個示例中,我們在輸入文本中找到 "THIS"
,將其替換,并保持替換文本的選中狀態(tài):
<input id="input" style="width:200px" value="Replace THIS in text">
<button id="button">Replace THIS</button>
<script>
button.onclick = () => {
let pos = input.value.indexOf("THIS");
if (pos >= 0) {
input.setRangeText("*THIS*", pos, pos + 4, "select");
input.focus(); // 聚焦(focus),以使選擇可見
}
};
</script>
如果未選擇任何內(nèi)容,或者我們在 setRangeText
中使用了相同的 start
和 end
,則僅插入新文本,不會刪除任何內(nèi)容。
我們也可以使用 setRangeText
在“光標處”插入一些東西。
這是一個按鈕,按下后會在光標位置插入 "HELLO"
,然后光標緊隨其后。如果選擇不為空,則將其替換(我們可以通過比較 selectionStart!=selectionEnd
來進行檢查,為空則執(zhí)行其他操作):
<input id="input" style="width:200px" value="Text Text Text Text Text">
<button id="button">Insert "HELLO" at cursor</button>
<script>
button.onclick = () => {
input.setRangeText("HELLO", input.selectionStart, input.selectionEnd, "end");
input.focus();
};
</script>
要使某些內(nèi)容不可選,有三種方式:
user-select: none
。<style>
#elem {
user-select: none;
}
</style>
<div>Selectable <div id="elem">Unselectable</div> Selectable</div>
這樣不允許選擇從 elem
開始。但是用戶可以在其他地方開始選擇,并將 elem
包含在內(nèi)。
然后 elem
將成為 document.getSelection()
的一部分,因此選擇實際發(fā)生了,但是在復(fù)制粘貼中,其內(nèi)容通常會被忽略。
onselectstart
或 mousedown
事件中的默認行為。<div>Selectable <div id="elem">Unselectable</div> Selectable</div>
<script>
elem.onselectstart = () => false;
</script>
這樣可以防止在 elem
上開始選擇,但是訪問者可以在另一個元素上開始選擇,然后擴展到 elem
。
當同一行為上有另一個事件處理程序觸發(fā)選擇時(例如 mousedown
),這會很方便。因此我們禁用選擇以避免沖突,仍然允許復(fù)制 elem
內(nèi)容。
document.getSelection().empty()
? 來在選擇發(fā)生后清除選擇。很少使用這種方法,因為這會在選擇項消失時導(dǎo)致不必要的閃爍。我們介紹了用于選擇的兩種不同的 API:
Selection
? 和 ?Range
? 對象。input
?,?textarea
?:其他方法和屬性。第二個 API 非常簡單,因為它處理的是文本。
最常用的方案一般是:
let selection = document.getSelection();
let cloned = /* 要將所選的節(jié)點克隆到的元素 */;
// 然后將 Range 方法應(yīng)用于 selection.getRangeAt(0)
// 或者,像這樣,用于所有范圍,以支持多選
for (let i = 0; i < selection.rangeCount; i++) {
cloned.append(selection.getRangeAt(i).cloneContents());
}
let selection = document.getSelection();
// 直接:
selection.setBaseAndExtent(...from...to...);
// 或者我們可以創(chuàng)建一個范圍并:
selection.removeAllRanges();
selection.addRange(range);
最后,關(guān)于光標。在諸如 <textarea>
之類的可編輯元素中,光標的位置始終位于選擇的起點或終點。我們可以通過設(shè)置 elem.selectionStart
和 elem.selectionEnd
來獲取光標位置或移動光標。
更多建議: