3

Web 中的“选区”和“光标”

 2 years ago
source link: https://segmentfault.com/a/1190000041457245
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

在 web 开发中,有时不可避免会和“选区”与“光标”打交道,比如选中高亮、选中出现工具栏、手动控制光标位置等。选区就是用鼠标选中的那一部分,通常是蓝色

image-20220224202846466

光标呢,是那个闪烁的竖线吗?

image-20220224203025183

温馨提示:文章比较长,耐心看完可以实现完全自主的操作选区和光标

一、“选区”和“光标”是什么?

先说结论:光标是一种特殊的选区

想搞清楚这个,不得不提到两个重要的对象:SelectionRange。这两个对象都有大量的属性和方法,详细可以查看官方文档,这里简单介绍一下:

  1. Selection 对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。通常由用户拖拽鼠标经过文字而产生。
  2. Range对象表示包含节点和部分文本节点的文档片段。通过 selection 对象获得的 range 对象才是我们操作光标的重点。

获取 selection ,可以通过全局的 getSelection 方法

const selection = window.getSelection();

img

通常情况下我们不会直接操作 selection 对象,而是需要操作用 seleciton 对象所对应的用户选择的 range 。获取方式如下:

const range = selection.getRangeAt(0);

img

为什么这里 getRangeAt需要传一个序列呢,难道选区还能有几个吗?还真是,只不过目前只有 Firefox 支持多选区,通过cmd键(windows 上是 ctrl键)可以实现多选区

img

可以看到,此时 selection 返回的 rangeCount为 5。不过大部分情况下都不需要考虑多选区的情况。

如果想获取选中的文本内容也非常简单,直接 toString 就可以了

window.getSelection().toString()
// 或者
window.getSelection().getRangeAt(0).toString()

img

再看一个range返回的一个属性,collapsed,表示选区的起点与终点是否重叠。当collapsedtrue时,选中区域被压缩成一个点,对于普通的元素,可能什么都看不到,如果是在可编辑元素上,那这个被压缩的点就变成了可以闪烁的光标。

img

所以,光标就是一种起始点相同的选区

二、可编辑元素

虽然选区和元素是否可编辑并没有直接关系,唯一的区别就是,在可编辑元素上可以看到光标,不过很多时候的需求都是针对可编辑元素的。

提到可编辑元素,一般有两种,一种是默认的表单输入框 inputtextarea

<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 选区操作

首先看这类元素的操作方式,几乎可以不用 sectionrange 相关 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();
}

img

如果想全部选中,可以直接用 select 方法

btn.onclick = () => {
    txt.select();
    txt.focus();
}

2. 聚焦到某一位置

如果我们想把光标移动到“阅文”的后面,根据前面所讲,光标其实是选区起始位置相同的产物,所以可以这样

btn.onclick = () => {
    txt.setSelectionRange(2,2); // 设置起始点相同
    txt.focus();
}

img

3. 还原之前的选区

有时候,我们需要在点击其他地方后,再重新选中之前的选区。这就需要先记录一下之前选区的起始位置,然后主动设置一下就行了

选区的起始位置,可以用 selectionStartselectionEnd这两个属性来获取,所以

const pos = {}
document.onmouseup = (ev) => {
   pos.start = txt.selectionStart;
   pos.end = txt.selectionEnd;
}
btn.onclick = () => {
    txt.setSelectionRange(pos.start,pos.end)
    txt.focus();
}

img

4. 在指定选区插入(替换)内容

表单输入框插入内容需要用到setRangeText方法,

inputElement.setRangeText(replacement);
inputElement.setRangeText(replacement, start, end [, selectMode]);

这个方法有两种形式,第2中形式有 4 个参数,第一个参数replacement ,表示需要替换的文本,然后startend是起始位置,默认是该元素当前选中区域,最后一个参数selectMode,表示替换后选区的状态,有 4 个可选项

  • select 替换后选中
  • start 替换后光标位于替换词之前
  • end 替换后光标位于替换词之后
  • preserve 默认值,尝试保留选区

比如,我们在选区插入或替换成一段文本“❤️❤️❤️”,可以这样:

btn.onclick = () => {
    txt.setRangeText('❤️❤️❤️')
    txt.focus();
}

img

上面有一个默认值“尝试保留选区” 是什么意思呢?假设手动选中的区域是[9,10],如果在[1,2]的位置替换新内容,那么选区仍然在之前位置。如果在[8,11]的位置替换新内容,由于新内容的位置覆盖了之前的选区,原选区也就不存在了,那么替换完之后,选区会选中刚刚插入的新内容

btn.onclick = () => {
    txt.setRangeText('❤️❤️❤️',5,10,'preserve')
    txt.focus();
}

img

以上完整代码可以访问 setSelectionRange & setRangeText (codepen.io),关于表单输入框的相关操作就到这里了,下面介绍普通元素的

四、普通元素的选区操作

首先,普通元素并没有以上方法

img

这就需要用到前面提到的sectionrange相关方法了,这里 API 也很多,还是从例子看起吧

1. 主动选中某一区域

首先需要主动创建一个Range对象,接着设置区域的起始位置,然后将这个对象添加到Section中就可以了。值得注意的是,设置区域起始位置的方法为 range.setStartrange.setEnd

range.setStart(startNode, startOffset);
range.setEnd(endtNode, endOffset);

为什么要分成两部分呢?原因在于普通元素的选区远比表单要复杂的多! 表单输入框里只有单一的文本,普通元素可能会包含多个元素

img

通过两个方法,可以把这两者之前的内容区域选中

添加到选区的方法是 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);
}

img

这里需要注意一点,在setStartsetEnd中设置的节点是txt.firstChild,而不是txt,这是为什么呢?

MDN 上是这么定义的:

如果起始节点类型是 TextComment , or CDATASection 之一, 那么 startOffset 指的是从起始节点算起字符的偏移量。 对于其他 Node 类型节点, startOffset 是指从起始结点开始算起子节点的偏移量。

什么意思呢?假设有一个这样的结构:

<div>yux阅文前端</div>

其实结构是这样的

img

所以如果将最外层的 div 作为起始节点,那么对于它本身来说,它只有1个文本节点,如果设置偏移为 2,浏览器就直接报错,由于只有一个文本节点,所以需要以它的第一个文本节点作为起始节点,也就是 firstChild,那样它就会以每个字符作为偏移量

2. 主动选中富文本中的某一区域

普通元素相比表单元素,最大的区别就是,支持内嵌标签,也就是富文本,假设这样一个 HTML

<div id="txt" contenteditable="true">yux<span>阅文</span>前端</div>

真实结构是这样的

img

我们也可以通过childNodes获取子节点

div.childNodes

img

如果要选中“阅文”该怎么做呢?

由于“阅文”是一个独立的标签,可以用到另外两个新的 API,range.selectNoderange.selectNodeContents,这两个都是表示选中某一节点,不同的是,selectNodeContents仅包含只节点,不包含自身

这里“阅文”所在的标签是第2个,所以

btn.onclick = () => {
  const selection = document.getSelection();
  const range = document.createRange();
  range.selectNode(txt.childNodes[1])
  selection.removeAllRanges();
  selection.addRange(range);
}

这里可以看看 selectNodeContentsselectNode 的具体区别,给 span 添加一个红色的样式,下面是selectNode的效果

img

再看selectNodeContents的效果

img

很明显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);
}

img

可以看到,这里的起始点都是相对于span元素的,而不是外层div的,这似乎有些不合常理?通常我们希望的肯定是针对最外层指定一个区间,比如 [2,5],不管你是什么结构,直接选中就行了,而不是像这样手动去找具体的标签,这该怎么处理呢?

选区最关键的一点就是获取起始点和结束点以及偏移量,如何通过相对外层的偏移量获取到最里层元素的信息呢?

假设有这样一段 HTML,稍微有点复杂

<div>yux<span>阅文<strong>前端</strong>团队</span></div>

试着找了很多官方文档,可惜并没有直接获取的 API,只能逐层遍历了。整体思路就是,先通过childNodes获取第一层的信息,被分成好几个区间,如果需要的偏移量在这个区间,就继续往里遍历,直到最底层,示意如下:

img

只要看红色部分(#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);
}

img

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);
}

img

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);
}

img

但是这种方式不太靠谱,存下来的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]
}

img

然后就可以利用这个偏移量,就主动选中该区域了

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);
}

img

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 来创建

img

还可以插入带标签的内容

btn.onclick = () => {
  const newNode = document.createElement('mark');
  newNode.textContent = '我是新内容' 
  lastRange.deleteContents()
  lastRange.insertNode(newNode)
}

img

插入的新内容默认是选中的,如果希望插入后光标在新内容后边,怎么处理呢

这时可以用到 range.setStartAfter 方法,表示设置区间的起点为该元素的后面,终点默认就是该元素的后面,不用处理,实现就是

btn.onclick = () => {
  const newNode = document.createElement('mark');
  newNode.textContent = '我是新内容' 
  lastRange.deleteContents()
  lastRange.insertNode(newNode)
  lastRange.setStartAfter(newNode)
  txt.focus()
}

img

6. 给指定选区包裹标签

最后再来看一个比较常见的例子,在选中时将所选区域包裹一层标签。

这个是有官方 API 支持的,需要用到 range.surroundContents 方法,表示给选区包裹一层标签

btn.onclick = () => {
  const mark = document.createElement('mark');
  lastRange.surroundContents(mark)
}

img

但是,这个方法有一个缺陷,当选区有“断层”时,比如这种情况,就会直接报错

img

这里可以用另一种方式,能够规避这个问题,和上面替换内容原理类似,不过需要先获取选区内容,获取选区内容可以通过 range.extractContents 方法,该方法返回的是一个 DocumentFragment 对象,将选区内容添加到新节点上,然后插入新内容,具体实现如下

btn.onclick = () => {
    const mark = document.createElement('mark');
  // 记录选区内容
  mark.append(lastRange.extractContents())
  lastRange.insertNode(mark) 
}

img

以上完整代码可以访问 Section & Range (codepen.io)

五、用两张图总结一下

如果完全掌握这些方法,相信对选区的处理可以游刃有余,记住一点,光标是一种特殊的选区,并且跟元素是否聚焦没什么关系,然后就是各种 API 了,这里用两张图列了一下大致关系

img

img

以上 API 并不全面,但覆盖了平时开发中了绝大部分场景,如果想了解更全面的属性和方法,可以上 MDN 查看。

随着 vue 、react 这些框架的流行,这些原生的 API 可能会很少有人提及,大部分的功能框架都帮我们做了封装,但总有一些功能是不满足的,这就必须要借助“原生的力量”了。最后,如果觉得还不错,对你有帮助的话,欢迎点赞、收藏、转发❤❤❤


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK