乐趣区

关于前端:Web-中的选区和光标

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

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

舒适提醒:文章比拟长,急躁看完能够实现齐全自主的操作选区和光标

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

先说论断:光标是一种非凡的选区

想搞清楚这个,不得不提到两个重要的对象:Section 和 Range。这两个对象都有大量的属性和办法,具体能够查看官网文档,这里简略介绍一下:

  1. Selection 对象示意用户抉择的文本范畴或插入符号的以后地位。它代表页面中的文本选区,可能横跨多个元素。通常由用户拖拽鼠标通过文字而产生。
  2. 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,示意选区的终点与起点是否重叠。当 collapsedtrue时,选中区域被压缩成一个点,对于一般的元素,可能什么都看不到,如果是在可编辑元素上,那这个被压缩的点就变成了能够闪动的光标。

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

二、可编辑元素

尽管选区和元素是否可编辑并没有间接关系,惟一的区别就是,在可编辑元素上能够看到光标,不过很多时候的需要都是针对可编辑元素的。

提到可编辑元素,个别有两种,一种是默认的表单输入框 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();}

如果想全副选中,能够间接用 select 办法

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

2. 聚焦到某一地位

如果咱们想把光标挪动到“阅文”的前面,依据后面所讲,光标其实是选区起始地位雷同的产物,所以能够这样

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

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

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

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

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

以上残缺代码能够拜访 setSelectionRange & setRangeText (codepen.io),对于表单输入框的相干操作就到这里了,上面介绍一般元素的

四、一般元素的选区操作

首先,一般元素并没有以上办法

这就须要用到后面提到的 sectionrange相干办法了,这里 API 也很多,还是从例子看起吧

1. 被动选中某一区域

首先须要被动创立一个 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);
}

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

MDN 上是这么定义的:

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

什么意思呢?假如有一个这样的构造:

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

其实构造是这样的

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

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

一般元素相比表单元素,最大的区别就是,反对内嵌标签,也就是富文本,假如这样一个 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);
}

这里能够看看 selectNodeContentsselectNode 的具体区别,给 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(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);
}

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

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

然而这种形式不太靠谱,存下来的 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]
}

而后就能够利用这个偏移量,就被动选中该区域了

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

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

还能够插入带标签的内容

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

插入的新内容默认是选中的,如果心愿插入后光标在新内容后边,怎么解决呢

这时能够用到 range.setStartAfter 办法,示意设置区间的终点为该元素的前面,起点默认就是该元素的前面,不必解决,实现就是

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

6. 给指定选区包裹标签

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

这个是有官网 API 反对的,须要用到 range.surroundContents 办法,示意给选区包裹一层标签

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

然而,这个办法有一个缺点,入选区有“断层”时,比方这种状况,就会间接报错

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

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

以上残缺代码能够拜访 Section & Range (codepen.io)

五、用两张图总结一下

如果齐全把握这些办法,置信对选区的解决能够熟能生巧,记住一点,光标是一种非凡的选区,并且跟元素是否聚焦没什么关系,而后就是各种 API 了,这里用两张图列了一下大抵关系

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

退出移动版