在 web 开发中,有时不可避免会和“选区”与“光标”打交道,比如选中高亮、选中出现工具栏、手动控制光标位置等。选区就是用鼠标选中的那一部分,通常是蓝色
光标呢,是那个闪烁的竖线吗?
温馨提示:文章比较长,耐心看完可以完全自主的操作选区和光标
先说结论:光标是一种特殊的选区。
想搞清楚这个,不得不提到两个重要的对象:Section 和 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 来操控选区。
首先看这类元素的操作方式,几乎可以不用 section
和 range
相关 API,可能更好理解一些。API 不太好记,直接看几个例子吧,这里以 textarea
为例
假设 HTML 如下
<textarea id="txt">阅文旗下囊括 QQ 阅读、起点中文网、新丽传媒等业界知名品牌,拥有 1450 万部作品储备,940 万名创作者,覆盖 200 多种内容品类,触达数亿用户,已成功输出包括《庆余年》《赘婿》《鬼吹灯》《琅琊榜》《全职高手》在内的动画、影视、游戏等领域的 IP 改编代表作。</textarea>
表单元素选中区域可以用到 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();
}
如果我们想把光标移动到“阅文”的后面,根据前面所讲,光标其实是选区起始位置相同的产物,所以可以这样
btn.onclick = () => {
txt.setSelectionRange(2,2); // 设置起始点相同
txt.focus();
}
有时候,我们需要在点击其他地方后,再重新选中之前的选区。这就需要先记录一下之前选区的起始位置,然后主动设置一下就行了
选区的起始位置,可以用 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();
}
表单输入框插入内容需要用到setRangeText方法,
inputElement.setRangeText(replacement);
inputElement.setRangeText(replacement, start, end [, selectMode]);
这个方法有两种形式,第2中形式有 4 个参数,第一个参数replacement
,表示需要替换的文本,然后start
和end
是起始位置,默认是该元素当前选中区域,最后一个参数selectMode
,表示替换后选区的状态,有 4 个可选项
比如,我们在选区插入或替换成一段文本“❤️❤️❤️”,可以这样:
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 也很多,还是从例子看起吧
首先需要主动创建一个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
,那样它就会以每个字符作为偏移量
普通元素相比表单元素,最大的区别就是,支持内嵌标签,也就是富文本,假设这样一个 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(children){
[...children].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);
}
这个就比较容易了,只需要把起始点设置相同就可以了,比如这里想把光标移动到“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);
}
这个有两种方式,第一种,可以先把之前的选区存下来,然后后面复原就行了
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(children){
[...children].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);
}
在选区插入内容,可以用到 range.insertNode 方法,它表示在选区的起点处插入一个节点,并不会替换掉当前已经选中的,如果要替换,可以先删除,删除需要用到 deleteContents 方法,具体实现就是
本文系作者在时代Java发表,未经许可,不得转载。
如有侵权,请联系nowjava@qq.com删除。