前言
前段时间,产品来了个需求:输入框中划词选中的内容匹配词槽。所以只能苦逼地在国庆加班搞一下(嗯,三倍还是挺香的),之前也模模糊糊地了解过浏览器有鼠标拖动选中内容的api,这次索性跟着需求彻底捋一遍Selection和Range这对双生子,顺便把踩过的坑记录一下。文章有点长,阅读可能需要花点时间~
先来看下实现后的需求效果,好有个感官上的认识。
特殊的前提
因为是在输入框内选中,并且选中的内容加上对应颜色和下面的列表呼应,所以这里只能采用富文本的方法,没错,就是在容器上增加contenteditable="true"
属性。不清楚的同学可以点击这里来了解更多,因为不是重点,咱们这就不过多展开了。当然,如果只是划词一般页面上的内容,那么就可以忽略这个前提。
先来看Range
Range本质上是页面上的一个起始边界点间的区域
:包含一个范围起点和范围终点。
不仅仅可以用于鼠标划词,页面上任何元素、文本都可以创建Range。
创建一个Range实例
构造函数Range()
返回一个新创建的Range对象
,新创建的对象属于全局Document对象。
1 | const range = new Range(); |
设置起始点
光有一个空Range对象实例并没有什么用,要设置起始位置才能确定一个范围区域。这里就用到了Range中的两个重要方法:
- setStart(startNode, startOffset):设置『起点』
- setEnd(endNode, endOffset):设置『终点』
这两个方法,都需要传入2个参数,第一个是文档中的某个节点,第二个是相对的偏移量,按照MDN上对这两个方法的的描述,具体又可以分为以下两种情况:
- startNode/endNode的节点类型是
Text
,Comment
或CDATASection
之一,那么startOffset/endOffset指的是从起始节点算起字符
的偏移量。 - 其他
Node
类型节点,startOffset/endOffset是指从起始节点开始算起子节点
的偏移量。
要知道节点的类型,可以参考nodeType加以判断,比如1为元素节点、3为文本节点、4为CDATASection、8为注释节点等。
从代码来举两个例子:
1 | const text = '北京招商银行信用卡今年冬天会有活动吗?'; |
因为box的firstChild是一个Text,所以设置完起点和终点的偏移量之后,range范围的内容就是『招商银行』这四个字符。这就是上面说的节点类型是Text
,Comment
或CDATASection
之一的情况。再来看第二种情况:
1 | const richtext = '北京<span huaci-id="bymvz9" style="background-color: rgb(197, 165, 171);">招商银行</span><span huaci-id="12mdc0" style="background-color: rgb(161, 226, 188);">信用卡</span>今年冬天会有<span huaci-id="cd2w41" style="background-color: rgb(239, 132, 142);">活动</span>吗?'; |
虽然打出来的内容是一样,但是此时的range是通过子元素的偏移获取到的~
总结,node既可以是文本节点,也可以是元素节点:对于文本节点,offset偏移的是字符数,而对于元素节点则是子节点数。注意,偏移量是左闭右开。
此外,还有一些其他的方法获取起点和重点,相对而言比较简单,这里就仅作罗列,不再详述。
- setStartBefore(node):将起点设置在node前面。
- setStartAfter(node):将起点设置在node后面。
- setEndBefore(node):将终点设置为node前面。
- setEndAfter(node):将终点设置为node后面。
Range的属性
Range生成后的属性非常有用,往往可以作为后续操作的依据。通过一张图,我们来了解下Range的属性概念:
- startContainer:起始节点,即上图中的<p>的第一个文本节点。
- startOffset:起始节点偏移量,为2。
- endContainer:结束节点,即上图中的<b>的第一个文本节点。
- endOffset:结束节点偏移量,为3。
- collapsed:范围的开始和结束是否为同一点,上图为false。
- commonAncestorContainer:在范围内的所有节点中最近的共同祖先节点,即上图中的<p>。
Range的其他方法
- 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将所选范围内容包裹起来。注意,所选范围必须包含其中所有元素的开始和结束标签,不完整的标签会导致方法失败并抛出error。
其中surroundContents方法,在这次需求中作为操作划词内容替换为span节点的主要api,必须在每次使用后将range清除出Selection,不然会不断触发document.onselectionchange
事件,非常尴尬。
基本上有了上述方法,我们就能随心所欲创建我们想要的任何范围的Range对象了。相比较起点终点,这些个方法都比较容易理解,使用的时候只要参照MDN文档就可以了,要注意实验特性的兼容性哈~
Selection登场
说完Range,接下来我们看看双生子中的另一位:Selection。
如果说Range是用于选择范围的对象,通过创建Range对象,来获取页面文档上的一个范围,那么Selection就是用来表示文档选择的。通过Firefox的一张图来看下(一个选择可以包括零个或多个范围,不过实际上只有Firefox是支持Selection里有多个Range,其余的高级浏览器只支持一个Range,一般我们也只用一个就足够,就忽略Firefox的这种多个情况吧):
如上图所示,蓝色就是Selection,也就是在文档中的选择范围。
获取全局Selection对象
可以通过api获取全局Selection对象:
1 | window.getSelection(); |
通常,如果鼠标滑动选中了内容,那么这个全局Selection对象的range就是我们当前划词的内容,当然,也是可以通过addRange
把创建的Range添加进去。相应的,通过getRangeAt
获取Selection的Range,因为一般都是一个,所以参数就是0(Firefox除外)。
1 | const range = new Range(); |
注:Selection的toString和Range的toString,都会返回被选中区域中的纯文本,要求变量为字符串的函数会自动对对象进行该处理。
关于光标
如果我们在应用中需要获取光标的位置,就可以用Selection对象的isCollapsed
属性,这个属性本来是标识选择是不是在同一位置(光标起始可以理解为类似的意思)。
1 | const sel = window.getSelection(); |
非常重要的选择事件
除了代码控制Selection和Range,最重要的还是要跟踪选择。我们可以通过下面两个事件跟踪选择:
element.onselectstart
:当选择从element上开始时触发,例如,用户按下鼠标键并开始移动鼠标。阻止默认行为会使选择无法开始。
document.onselectionchange
:当选择发生变化时触发,例如,用户在页面文档里从一个划词到另一个划词。重要:此事件只能绑定在document上。
在实际使用时,最常用的是document.onselectionchange
事件,在使用时候要像事件委托一样处理,所以要配合window.getSelection
获取到的对象,根据进一步的信息判断是否要执行下一步代码。那么,什么是进一步的信息呢?当然就是Selection的属性啦~
Selection的属性
首先要清楚,选择的起点称为锚点(anchor)
,终点称为焦点(focus)
。
- anchorNode:选择的起始节点。
- anchorOffset:选择开始的anchorNode中的偏移量。
- focusNode:选择的结束节点。
- focusOffset:选择开始处focusNode的偏移量。
- isCollapsed:如果未选择任何内容(空范围)或不存在,则为true。
- rangeCount:选择中的范围数,之前说过,除Firefox外,其他浏览器最多为1。
起始细细品味,Selection和Range的属性有异曲同工之妙,都可以作为下一步操作的依据。但这里要提的是Range的起点不能在终点之后,但Selection的锚点和焦点则可以随意前后,因为我们可以向前选择,也可以向后拖动~
Selection的方法
选择的方法不少,其中和Range相关的有:
- getRangeAt(i):获取第i个Range。
- addRange(range):将Range添加到选择中。如果选择已有关联的范围,则除Firefox外的所有浏览器都将忽略该调用。
- removeRange(range):从选择中删除Range。
- removeAllRanges():删除所有范Range。
- empty():removeAllRanges的别名。
从上述方法就可知我们可以随意操作Range和Selection的转换。
还有一些和Range无关的方法:
- collapse(node, offset):用一个新的范围替换选定的范围,该新范围从给定的node处开始,到偏移offset处结束。
- setPosition(node, offset):collapse的别名。
- collapseToStart():折叠(替换为空范围)到选择起点,
- collapseToEnd():折叠到选择终点,
- extend(node, offset):将选择的焦点(focus)移到给定的node,位置偏移offset,
- setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset):用给定的起点anchorNode/anchorOffset和终点focusNode/focusOffset来替换选择范围。选中它们之间的所有内容。
- selectAllChildren(node):选择 node 的所有子节点。
- deleteFromDocument():从文档中删除所选择的内容。
- containsNode(node, allowPartialContainment = false):检查选择中是否包含 node(特别是如果第二个参数是 true 的话)。
所以要选择一个范围,可以通过创建Range,然后添加进Selection。反之,不创建Range,通过Selection的方法,仍然可以实现选择一个范围。关键就是对于元素的定位和偏移的计算。具体问题具体分析,不盲从某个方法,理解内在原理,往往能达到事半功倍的效果。
如果选择已存在,则首先使用removeAllRanges()将其清空。然后再addRange()来添加范围。不然除了Firefox,其他浏览器都不会理你~
实战需求
基本概念说了七七八八了,接下来就把实战中遇到的点点滴滴记录一下。
关于输入框内变色的问题
输入框最基本的就是input和textarea,但经过调研,并不能满足部分文字颜色变换的要求,所以只能通过contenteditable=”true”来当富文本操作。
初始化创建变色的范围
既然定了富文本,那么自然会记录输入内容的富文本字符串。我是根据将富文本挂载进DOM后,然后选取富文本节点下的子节点childNodes
。通过childNodes的遍历,对不是文本节点的,且具有『huaci-id』的span元素,依次创建范围。具体代码如下所示:
1 | // 假设页面文档已经挂载,如下形式: |
这里省略了业务的部分,只是把创建的过程罗列,通过对富文本子节点的遍历,正好依次能创建,也是非常的完美。
输入框的划词弹框
在上面的gif图中,通过划词,能够弹出一个选择框,这个就是通过监听document.onselectionchange
事件来实现的。这里记录两点:
- 因为事件类似委托的形式,所以在页面上滑动,会频繁触发,所以要用
debounce
来缓解这个问题。1
2
3document.onselectionchange = debounce((e) => {
// ...
} - 对于要进行操作的元素,要有一些判断,判断的依据,仍然是上文说的Range或Selection的属性。比如我在业务中的一些过滤用的判断条件:
- 如果是光标插入,比如鼠标随便点了下,跳过不做后续操作。
1
if (selection.isCollapsed) return;
- 如果鼠标最终在输入框啥也没选或仅仅选了空格,跳过不做后续操作。
1
2
3const range = selection.getRangeAt(0);
const content = range.toString().trim();
if (!content) return; - 如果划词落地的起点节点是文本节点,并且它的父节点是个带有『huaci-wrap』class的节点,也是直接跳过。
1
2
3
4const { commonAncestorContainer, startContainer, endContainer } = range;
if (startContainer.nodeType === 3 && startContainer.parentNode.nodeType === 1 && startContainer.parentNode.classList.contains('huaci-wrap')) {
return;
} - 如果划词落地的结束节点是文本节点,并且它的父节点是个带有『huaci-wrap』class的节点,也是直接跳过。
1
2
3
4const { commonAncestorContainer, startContainer, endContainer } = range;
if (endContainer.nodeType === 3 && endContainer.parentNode.nodeType === 1 && endContainer.parentNode.classList.contains('huaci-wrap')) {
return;
}
- 如果是光标插入,比如鼠标随便点了下,跳过不做后续操作。
那么过滤了那么多,什么才是可以执行后续操作的呢~哈哈,在这个需求里,条件是这样的:
1 | const isMatch = |
聪明的你根据上面的说明,肯定一眼就能看出这个符合条件的含义了~(我懒得写了)
选中文本的高亮
那么最后的操作就是让刚才符合条件的选中的文本,再弹窗确定后加一个背景色,背景色是一个随机生成的颜色,代码也放一下,非常实用:
1 | const REG_HEX = /(^#?[0-9A-F]{6}$)|(^#?[0-9A-F]{3}$)/i; |
既然颜色有了,那么最后在点击确定后的回调里把输入框的文本变色。我的思路是,通过创建一个内容为选中的范围文案的span,替换原来位置的文本,关键代码如下:
1 | const selection = document.getSelection(); |
需要注意的是,最后的removeRange
很重要,因为surroundContents
用新的节点替换,起始又会触发document.onselectionchange
事件,如果不清除,就会陷入无限循环,这个是很重要的点。
总结
自此,这个功能就基本成型,经过这个需求后,更加深了Selection和Range在实战中的应用。起始在很多阅读站点上(比如Medium)或翻译功能上,也有类似的划词操作,也是个非常有意思的功能。到目前为止写的最长的一篇,加班也结束了,趁着假期的尾巴,好好休息下~