在本章中,我们将介绍文档中的选择,以及表单字段(如 <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)
设置范围以选择整个node
selectNodeContents(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…)