在本章中,我们将介绍文档中的选择,以及表单字段(如 <input>)中的选择。
JavaScript 可以访问现有选择,选择/取消选择整个或部分 DOM 节点,从文档中删除选定内容,将其包装到标签中,等等。
您可以在本章末尾的“摘要”部分找到一些针对常见任务的食谱。也许这涵盖了您当前的需求,但如果您阅读全文,您将获得更多收益。
底层的 Range 和 Selection 对象很容易理解,然后您就不需要任何食谱来让它们做您想做的事情。
范围
选择的根本概念是 范围,它本质上是一对“边界点”:范围开始和范围结束。
Range 对象是在没有参数的情况下创建的
let range = new Range();
然后我们可以使用 range.setStart(node, offset) 和 range.setEnd(node, offset) 设置选择边界。
正如你可能猜到的,接下来我们将使用Range对象进行选择,但首先让我们创建一些这样的对象。
部分选择文本
有趣的是,两种方法中的第一个参数node可以是文本节点或元素节点,第二个参数的含义取决于此。
如果node是文本节点,则offset必须是其文本中的位置。
例如,给定元素<p>Hello</p>,我们可以创建包含字母“ll”的范围,如下所示
<p id="p">Hello</p>
<script>
let range = new Range();
range.setStart(p.firstChild, 2);
range.setEnd(p.firstChild, 4);
// toString of a range returns its content as text
console.log(range); // ll
</script>
这里我们取<p>的第一个子节点(即文本节点),并指定其内部的文本位置
选择元素节点
或者,如果node是元素节点,则offset必须是子节点编号。
这对于创建包含整个节点的范围非常方便,而不是在节点文本内部的某个地方停止。
例如,我们有一个更复杂的文档片段
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
这是它的 DOM 结构,包含元素节点和文本节点
让我们为"Example: <i>italic</i>"创建一个范围。
正如我们所见,这个短语正好包含<p>的两个子节点,索引分别为0和1
-
起点以
<p>作为父node,0作为偏移量。因此我们可以将其设置为
range.setStart(p, 0)。 -
终点也以
<p>作为父node,但偏移量为2(它指定了直到但不包括offset的范围)。因此我们可以将其设置为
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 of a range returns its content as text, without tags
console.log(range); // Example: italic
// apply this range for document selection (explained later below)
document.getSelection().addRange(range);
</script>
这是一个更灵活的测试台,你可以设置范围的开始/结束数字,并探索其他变体
<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);
// apply the selection, explained later below
document.getSelection().removeAllRanges();
document.getSelection().addRange(range);
};
</script>
例如,在同一个<p>中从偏移量1到4进行选择,会得到范围<i>italic</i> and <b>bold</b>
我们不必在setStart和setEnd中使用相同的节点。一个范围可以跨越许多不相关的节点。重要的是,结束位置在文档中必须在开始位置之后。
选择更大的片段
让我们在我们的示例中进行更大的选择,就像这样
我们已经知道如何做到这一点。我们只需要在文本节点中设置开始和结束作为相对偏移量。
我们需要创建一个范围,它
- 从
<p>第一个子节点中的位置 2 开始(取“Example: ”的前两个字母以外的所有字母) - 在
<b>第一个子节点中的位置 3 结束(取“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
// use this range for selection (explained later)
window.getSelection().addRange(range);
</script>
正如你所见,创建我们想要的任何范围都相当容易。
如果我们想将节点作为一个整体,可以在setStart/setEnd中传递元素。否则,我们可以在文本级别上进行操作。
范围属性
我们在上面示例中创建的范围对象具有以下属性
startContainer,startOffset– 起始位置的节点和偏移量,- 在上面的示例中:
<p>内部的第一个文本节点和2。
- 在上面的示例中:
endContainer,endOffset– 结束位置的节点和偏移量,- 在上面的示例中:
<b>内部的第一个文本节点和3。
- 在上面的示例中:
collapsed– 布尔值,如果范围的起始位置和结束位置相同(因此范围内部没有内容),则为true,- 在上面的示例中:
false
- 在上面的示例中:
commonAncestorContainer– 范围内的所有节点的最近公共祖先,- 在上面的示例中:
<p>
- 在上面的示例中:
范围选择方法
有很多方便的方法可以操作范围。
我们已经看到了 setStart 和 setEnd,这里还有其他类似的方法。
设置范围起始位置
setStart(node, offset)设置起始位置:node中的offset位置setStartBefore(node)设置起始位置:node之前setStartAfter(node)设置起始位置:node之后
设置范围结束位置(类似方法)
setEnd(node, offset)设置结束位置:node中的offset位置setEndBefore(node)设置结束位置:node之前setEndAfter(node)设置结束位置:node之后
从技术上讲,setStart/setEnd 可以做任何事情,但更多的方法提供了更多便利。
在所有这些方法中,node 可以是文本节点或元素节点:对于文本节点,offset 跳过那么多字符,而对于元素节点,跳过那么多子节点。
更多创建范围的方法
selectNode(node)设置范围以选择整个nodeselectNodeContents(node)设置范围以选择整个node的内容collapse(toStart)如果toStart=true,则设置 end=start,否则设置 start=end,从而折叠范围cloneRange()创建一个具有相同起始位置/结束位置的新范围
范围编辑方法
创建范围后,我们可以使用以下方法操作其内容
deleteContents()– 从文档中删除范围内容extractContents()– 从文档中删除范围内容并作为 DocumentFragment 返回cloneContents()– 克隆范围内容并作为 DocumentFragment 返回insertNode(node)– 将node插入到范围开始处的文档中surroundContents(node)– 将node包裹在范围内容周围。要使此方法起作用,范围必须包含其内部所有元素的开始和结束标签:不能使用像<i>abc这样的部分范围。
使用这些方法,我们可以对选定的节点做任何操作。
以下是一个测试台,用于演示这些方法的实际应用
Click buttons to run methods on the selection, "resetExample" to reset it.
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
<p id="result"></p>
<script>
let range = new Range();
// Each demonstrated method is represented here:
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>
还有一些方法可以比较范围,但这些方法很少使用。当您需要它们时,请参考 规范 或 MDN 手册。
选择
Range 是一个用于管理选择范围的通用对象。但是,创建 Range 并不意味着我们在屏幕上看到选择。
我们可以创建 Range 对象,并将它们传递给其他函数 - 它们本身不会在视觉上选择任何内容。
文档选择由 Selection 对象表示,可以通过 window.getSelection() 或 document.getSelection() 获取。选择可以包含零个或多个范围。至少,选择 API 规范 是这样说的。但在实践中,只有 Firefox 允许通过使用 Ctrl+click (Cmd+click for Mac) 在文档中选择多个范围。
以下是在 Firefox 中创建的包含 3 个范围的选择的屏幕截图
其他浏览器最多支持 1 个范围。正如我们将看到的,一些 Selection 方法暗示可能存在多个范围,但同样,在除 Firefox 之外的所有浏览器中,最多只有 1 个范围。
以下是一个小型演示,它以文本形式显示当前选择(选择一些内容并单击)
选择属性
如前所述,选择理论上可以包含多个范围。我们可以使用以下方法获取这些范围对象
getRangeAt(i)– 获取第 i 个范围,从0开始。在除 Firefox 之外的所有浏览器中,只使用0。
此外,还有一些属性通常提供更好的便利性。
与范围类似,选择对象也有一个起点,称为“锚点”,和一个终点,称为“焦点”。
主要的选择属性是
anchorNode– 选择开始处的节点,anchorOffset– 选择在anchorNode中开始处的偏移量,focusNode– 选择结束处的节点,focusOffset– 选择在focusNode中结束处的偏移量,isCollapsed– 如果选择未选择任何内容(空范围),或不存在,则为true。rangeCount– 选择范围的计数,在所有浏览器中最大值为1,除了 Firefox。
选择锚点/焦点与 Range 开始/结束之间存在重要区别。
众所周知,Range 对象的开始始终在结束之前。
对于选择,情况并非总是如此。
使用鼠标选择可以在两个方向上进行:“从左到右”或“从右到左”。
换句话说,当按下鼠标按钮,然后在文档中向前移动时,其结束(焦点)将在其开始(锚点)之后。
例如,如果用户从鼠标开始选择并从“Example”移动到“italic”
…但是相同的选择可以反向进行:从“italic”开始到“Example”(反向方向),然后其结束(焦点)将在开始(锚点)之前。
选择事件
有一些事件可以跟踪选择。
elem.onselectstart– 当选择开始于元素elem上(或其内部)时。例如,当用户在元素上按下鼠标按钮并开始移动指针时。- 阻止默认操作会取消选择开始。因此,从该元素开始选择变得不可能,但该元素仍然可以选择。访问者只需要从其他地方开始选择。
document.onselectionchange– 每当选择发生变化或开始时。- 请注意:此处理程序只能在
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 and focusNode are text nodes usually
from.value = `${anchorNode?.data}, offset ${anchorOffset}`;
to.value = `${focusNode?.data}, offset ${focusOffset}`;
};
</script>
选择复制演示
有两种方法可以复制选定的内容。
- 我们可以使用
document.getSelection().toString()将其作为文本获取。 - 否则,要复制完整的 DOM,例如,如果我们需要保留格式,我们可以使用
getRangeAt(...)获取底层的范围。Range对象反过来具有cloneContents()方法,该方法克隆其内容并作为DocumentFragment对象返回,我们可以将其插入其他位置。
这是将选定内容作为文本和 DOM 节点复制的演示。
<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 = "";
// Clone DOM nodes from ranges (we support multiselect here)
for (let i = 0; i < selection.rangeCount; i++) {
cloned.append(selection.getRangeAt(i).cloneContents());
}
// Get as text
astext.innerHTML += selection;
};
</script>
选择方法
我们可以通过添加/删除范围来处理选择。
getRangeAt(i)– 获取第 i 个范围,从0开始。在除 Firefox 之外的所有浏览器中,只使用0。addRange(range)– 将range添加到选择。除了 Firefox 之外的所有浏览器都会忽略该调用,如果选择已经关联了范围。removeRange(range)– 从选择中移除range。removeAllRanges()– 移除所有范围。empty()–removeAllRanges的别名。
还有一些方便的方法可以直接操作选择范围,无需中间的 Range 调用。
collapse(node, offset)– 用一个新的范围替换选定范围,该范围从给定的node开始,在位置offset结束。setPosition(node, offset)–collapse的别名。collapseToStart()– 折叠(用空范围替换)到选择开始,collapseToEnd()– 折叠到选择结束,extend(node, offset)– 将选择的焦点移动到给定的node,位置offset,setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset)– 用给定的开始anchorNode/anchorOffset和结束focusNode/focusOffset替换选择范围。它们之间的所有内容都被选中。selectAllChildren(node)– 选择node的所有子节点。deleteFromDocument()– 从文档中删除选定的内容。containsNode(node, allowPartialContainment = false)– 检查选择是否包含node(如果第二个参数为true,则部分包含)。
对于大多数任务,这些方法就足够了,无需访问底层的 Range 对象。
例如,选择段落 <p> 的全部内容
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
<script>
// select from 0th child of <p> to the last child
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); // or selectNode(p) to select the <p> tag too
document.getSelection().removeAllRanges(); // clear existing selection if any
document.getSelection().addRange(range);
</script>
如果文档选择已经存在,请先使用 removeAllRanges() 清空它。然后添加范围。否则,除了 Firefox 之外的所有浏览器都会忽略新范围。
例外情况是一些替换现有选择的选择方法,例如 setBaseAndExtent。
表单控件中的选择
表单元素,例如 input 和 textarea 提供了 选择专用 API,无需 Selection 或 Range 对象。由于输入值是纯文本,而不是 HTML,因此不需要这样的对象,一切都简单得多。
属性
input.selectionStart– 选择开始的位置(可写),input.selectionEnd– 选择结束的位置(可写),input.selectionDirection– 选择方向,其中之一:“forward”、“backward” 或 “none”(例如,如果使用双击鼠标选择),
事件
input.onselect– 当选择某些内容时触发。
方法
-
input.select()– 选择文本控件中的所有内容(可以是textarea而不是input), -
input.setSelectionRange(start, end, [direction])– 将选择范围更改为从位置start到end,在给定方向(可选)。 -
input.setRangeText(replacement, [start], [end], [selectionMode])– 用新文本替换一段文本。可选参数
start和end,如果提供,则设置范围的开始和结束,否则使用用户选择。最后一个参数
selectionMode决定文本替换后如何设置选择。可能的值是"select"– 新插入的文本将被选中。"start"– 选择范围折叠到插入文本之前(光标将位于插入文本之前)。"end"– 选择范围折叠到插入文本之后(光标将位于插入文本之后)。"preserve"– 尝试保留选择。这是默认值。
现在让我们看看这些方法的实际应用。
示例:跟踪选择
例如,这段代码使用 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在选择内容时触发,但在移除选择时不会触发。document.onselectionchange事件不应该在表单控件内的选择时触发,根据 规范,因为它与document选择和范围无关。一些浏览器会生成它,但我们不应该依赖它。
示例:移动光标
我们可以更改 selectionStart 和 selectionEnd,这将设置选择。
一个重要的边缘情况是 selectionStart 和 selectionEnd 相等。那么它就是光标的位置。或者,换句话说,当没有选择时,选择会在光标位置折叠。
因此,通过将 selectionStart 和 selectionEnd 设置为相同的值,我们移动光标。
例如
<textarea id="area" style="width:80%;height:60px">
Focus on me, the cursor will be at position 10.
</textarea>
<script>
area.onfocus = () => {
// zero delay setTimeout to run after browser "focus" action finishes
setTimeout(() => {
// we can set any selection
// if start=end, the cursor is exactly at that place
area.selectionStart = area.selectionEnd = 10;
});
};
</script>
示例:修改选择
要修改选择的内容,我们可以使用 input.setRangeText() 方法。当然,我们可以读取 selectionStart/End,并根据选择更改 value 的相应子字符串,但 setRangeText 更强大,通常更方便。
这是一个比较复杂的方法。在最简单的单参数形式中,它替换用户选择范围并移除选择。
例如,这里用户选择将被 *...* 包裹。
<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; // nothing is selected
}
let selected = input.value.slice(input.selectionStart, input.selectionEnd);
input.setRangeText(`*${selected}*`);
};
</script>
使用更多参数,我们可以设置范围start和end。
在这个例子中,我们在输入文本中找到"THIS",替换它并保持替换后的内容被选中。
<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 to make selection visible
}
};
</script>
示例:在光标处插入
如果没有任何内容被选中,或者我们在setRangeText中使用相同的start和end,那么新文本只是被插入,没有任何内容被删除。
我们也可以使用setRangeText在“光标处”插入一些内容。
这里有一个按钮,在光标位置插入"HELLO",并将光标立即放在它的后面。如果选择不为空,那么它将被替换(我们可以通过比较selectionStart!=selectionEnd来检测它,并执行其他操作)。
<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>
使不可选
要使某些内容不可选,有三种方法
-
使用 CSS 属性
user-select: none。<style> #elem { user-select: none; } </style> <div>Selectable <div id="elem">Unselectable</div> Selectable</div>这不允许选择从
elem开始。但用户可以在其他地方开始选择,并将elem包含在其中。然后
elem将成为document.getSelection()的一部分,因此实际上发生了选择,但其内容通常在复制粘贴中被忽略。 -
在
onselectstart或mousedown事件中阻止默认操作。<div>Selectable <div id="elem">Unselectable</div> Selectable</div> <script> elem.onselectstart = () => false; </script>这将阻止在
elem上开始选择,但访问者可以在另一个元素上开始选择,然后扩展到elem。当同一个动作上还有另一个事件处理程序触发选择(例如
mousedown)时,这很方便。因此,我们禁用选择以避免冲突,仍然允许复制elem的内容。 -
我们也可以在选择发生后使用
document.getSelection().empty()清除选择。这很少使用,因为这会导致不必要的闪烁,因为选择会显示和消失。
参考资料
总结
我们介绍了两种不同的选择 API
- 对于文档:
Selection和Range对象。 - 对于
input、textarea:额外的使用方法和属性。
第二个 API 非常简单,因为它使用文本。
最常用的方法可能是
- 获取选择
let selection = document.getSelection(); let cloned = /* element to clone the selected nodes to */; // then apply Range methods to selection.getRangeAt(0) // or, like here, to all ranges to support multi-select for (let i = 0; i < selection.rangeCount; i++) { cloned.append(selection.getRangeAt(i).cloneContents()); } - 设置选择
let selection = document.getSelection(); // directly: selection.setBaseAndExtent(...from...to...); // or we can create a range and: selection.removeAllRanges(); selection.addRange(range);
最后,关于光标。可编辑元素(如<textarea>)中的光标位置始终位于选择的开头或结尾。我们可以使用它来获取光标位置,或者通过设置elem.selectionStart和elem.selectionEnd来移动光标。
评论
<code>标签,对于多行代码,请使用<pre>标签,对于超过 10 行的代码,请使用沙箱(plnkr,jsbin,codepen…)