可能有人会说真正实现富文本时,是不会用到 Selection 与 Range 等浏览器原生 api 的,为什么还要了解他?
讲真,我们可以不学习 document.execCommand() 命令, 但是不能不学习 Selection 与 Range,因为 document.execCommand() 所执行的命令不过是对选区的内容进行操作,直接查询 api 有那些命令即可,并且这部分内容在真正实现编辑器的实战中目前是不会再用到的,但 Selection 与 Range 不同,很多编辑器框架中进行文本选择,最终是由框架(如 Prosemirror,slate.js,quill,draft.js等)对 Selection Range 进行封装后提供给我们使用的。所以学习 Selection 与 Range 对后面学习使用 Prosemirror, slate.js 等框架时,理解他们的选区以及使用的底层技术有重要意义。
除此之外,在使用 contenteditable 元素替换 textarea 元素时,如果要完全拦截输入,自己进行内容拼装,也需要深入理解 Selection 与 Range 才能较好地实现它。
避免杠精专用说明:使用 contenteditable 替换 textarea 并非易事(需要考虑输入内容不包含标签,处理粘贴,换行,不同浏览器兼容问题):以下为知名前端前辈张鑫旭写的两篇 contenteditable 替换 textarea 的文章,实现方案尚且存在较多问题,无法投入实际项目,要以较高完成度实现 textarea 的替代品,学习 Selection 与 Range 也是必须的。
1. 选区 Selection 概念
在实现富文本编辑器过程中,有个重要概念为**“选区”**,在原生浏览器中,选区由 Selection 对象表示。Selection 既可以表示鼠标滑过的一个文本选中区间,也可以表示光标位置(在 contenteditable 为 "true" 的元素中)。
选区中务必要明白 anchor (锚点) 与 focus (焦点) 的概念,这里 anchor 代表选择文本时鼠标按下开始选择的位置,focus 表示选择文本结束时,鼠标所处的位置,这里的 focus 与元素的 focus 事件不是一个概念。
从左到右选择
从右向左选择
上方分别展示了从不同方向选择文本后,anchor 与 focus 的位置,简单点来讲,它是与选择方向相关的。中间被选择的部分被称为 Ranges (选择范围),在 chrome 中,每个选区 (Selection)中最多只能有一个 选择范围 (Range),在 Firfox 中,可以通过按下 ctrl/cmd 键,选择多个 Ranges。但通常开发过程中,在操作 Selection 中的 Range 时,我们会以 Chrome 中的规定来操作,因为 Selection 中即使放多个 Ranges, Chrome 也只会识别第一个。
2. 浏览器中的 Selection 对象
2.1 区分 Selection 与 Range 的概念
在浏览器中,可以通过 getSelection
获取选区对象,后续不论是获取选区的内容,还是通过代码修改被选中的文本,都使用此方法获取 Selection,不需要自己去创建选区。
const selection = window.getSelection();
被选中的文本都不直接在 Selection 中,而是存在一个个 Range 中,Chrome 中只能识别 Selection 中的第一个 range,即按住 ctrl 或 cmd 后,继续选择其他文本,Chrome 会将之前的选中清除掉,目前只有 Firefox 可以选中多个文本块。如下图所示:
通过 selection.rangeCount
属性可以查看当前 选区 (Selection) 中包含多少个 Range,通过 selection.getRangeAt(index)
可以获取到选区中第 index 个被选中的块。
const selection = window.getSelection();
console.log(selection.rangeCount); // 以上方图中为例,chrome 中 rangeCount 为 1(在 chrome 中最多就是 1),firefox 中为 3
const firstRange = selection.getRangeAt(0); // 被选中的内容,即上方粉红色的部分
2.2 Selection 实例的核心属性
Selection 中的三个重要概念就是上方我们讲的 anchor, focus, ranges, 因此,Selection 的属性主要就围绕着几个概念展开。
anchor 与 focus
在 Selection 中,anchor 锚点相关的属性有 anchorNode
与 anchorOffset
,focus 焦点相对应的属性也有 focusNode
与 focusOffset
,且这几个属性都是只读的。
如上图所示,结合最开始 anchor 的概念,Selection对象上针对 anchor 有个 anchorNode
代表锚点所在的节点,focusNode
代表焦点所在的节点,一般情况下节点类型为 TextNode
。对于 anchorOffset
与 focusOffset
,他们的计算方式都是从左到右计算到选区的边界,总共有多少个字符。上方 anchorOffset 即 hello world,
文本节点,从开始到 Range 边缘,字符数量为 6,即偏移量6;foucsOffset 也是一样的道理,a sample paragraph
从开头,到 Range 边界,有 8 个字符,即偏移量为8。
当然,如果将选择方向变换一下,则 anchorNode 与 focusNode 就会交换,anchorOffset 与 focusOffset 的计算方式不变。您可以自行计算一下相同的内容被反选后 anchorOffset 与 focusOffset 分别是多少,再看下图是否理解正确。
图误,右侧鼠标下方应该是 start,写错不想改了
anchorNode、focusNode 与 anchorOffset、focusOffset 的特殊情况
上面介绍了几种一般情况,当然,除了一般情况还有特殊情况,使用下面的代码手动设置选区的 Range,看看会发生什么情况:
// html content: <p>hello world, <span>this is</span> <i>a sample paragraph.</i></p>
const selection = window.getSelection();
// 选区中包含被选中的部分,则将其全部移除,即清空被选中的文本
if (selection.rangeCount > 0) {
selection.removeAllRanges();
}
const italics = document.getElementsByTagName('i');
// 自己创建一个被选中的范围
const range = document.createRange();
// 被选中的范围中要选中第一个 i 标签中的内容
range.selectNode(italics[0]);
// 将范围添加到选区中,此时斜体文本会被选中
selection.addRange(range);
页面中斜体标记的元素 a sample paragraph
被选中了,按之前的分析,anchorNode 与 focusNode 都应该是 TextNode,anchorOffset 是选区左边界到文本的节点被选中字符的字符数,偏移量应该是 0,focusOffset 应该是文本节点开始位置到选区有边界的字符数,偏移量应该是19。但打印出的结果却与预期不符,anchorNode 与 focusNode 均为父节点 p,偏移量也不对,一瞬间也看不懂 3 和 4 是怎么计算出来的。
其实这与 range api 调用有关,上方将 i
Node 直接添加到了选区中,此时添加选区后,anchorNode 与 focusNode 都是被添加的这个元素的父节点。anchorOffset 则表示在 p 标签内,节点 i
(被选中的第一个节点)前面的同级节点有几个,这里有3个,分别是[TextNode("hello world, "), SpanNode(<span>this is</span>), TextNode(" ")]
;foucsOffset 则表示在 p 标签内,节点 i
末尾(被选中的最后一个节点,这里恰好被选中的只有一个节点,所以都是 i)前面共有几个节点,这里是 4 个;如下图分析:
Selection 实例的其他属性
- isCollapsed:
isCollapsed
用来判断选区的起始点与终点是否在同一个位置,即当前选区是光标所在的位置。 - rangCount: 上面已经使用过了,查看选取内有几段被选中的文本,chrome 中最多只有 1 个,firefox 中可以有多个
- type: 实验属性(兼容性看起来还可以),三个值
- None: 没有内容被选中
- Text: 有文本被选中
- Caret: 当前光标为聚焦状态,isCollapsed 为 true 时,一般 type 为 Caret
- baseNode: anchorNode 的别名,一般不使用
- extendNode: focusNode 的别名,一般也不用
2.3 Selection 实例的一些方法
设置光标位置
在一个可编辑的元素内,想要将光标移动到自己需要的位置时,可以使用 Selection 提供的几个 collapse 方法:
首先介绍最常用的 Selection.collapse(parentNode: Node, offset?: number)
, 下方示例中,获取了最后的斜体元素,通过 selection.collapse(italics[0].firstChild, 2)
将光标移动到斜体元素内的 TextNode("a sample paragraph") 文本元素偏移量为 2 的地方。
Selection.collapse还有个别名方法
setPosition(parentNode, offset?)
, 一般就用 collapse
这里要注意,最好在设置偏移量的时候,我们传入的父节点是文本节点(TextNode),此时我们会很容易控制光标插入的位置。如果没有设置偏移量,则偏移量默认为0。上面因为父节点是 TextNode, 因此偏移量的计算是从文本开头算的,一个偏移量也就是一个文本字符。如果父节点传入的是p
元素,里面有四个节点 [TextNode('hello world, ', Span(TextNode('this is'))), TextNode(' '), Italic(TextNode('a sample paragraph'))]
,此时设置偏移量时,偏移量的单位就是节点。例如:selection.collapse(p[0], 1)
, 光标会跑到 “hello world, ” 后面;如图:
使用 collapse 的时候一定要注意 parentNode 与 offset,如果传入 offset 不对,是会报错的,建议传入的 parentNode 是 文本节点,此时 offset 对应文本字符数量,否则,offset 对应的是 parentNode 中 Node 节点的数量,不好把控。
其他设置光标API
selection.collapseToStart()
: 将光标移动到当前选区的开头,例如当前选择了文本123
,调用 api 后,光标会在 1 之前selection.collapseToEnd()
: 将光标移动到当前选区的结尾,例如当前选择了文本 123,调用 api 后,光标会在 3 之后
选区内容的操作相关方法
简单过一遍 api,简单了解一下 selection 提供了哪些能力,api 调用时有哪些注意事项。
-
selection.toString()
: 返回选区文本字符串 -
selection.containsNode(node, aPartlyContained?)
:传入一个 dom 节点,可以判断当前节点是否处于选区中,如果节点的部分文本在选区中,第二个参数为 true 时,此时会返回 true, 否则是 false, 但 TextNode 不同,如果 TextNode 的部分处于选区中,不管第二个参数传什么,都会返回 true,此时要注意自己的用法是否正确。 -
selection.deleteFromDocument()
,selection.empty()
是deleteFromDocument
的别名: 将选区内容从文档中删除,注意此方法操作后,通过 ctrl + z 是无法进行内容恢复的。删除时,如果是选区范围没有包含整个节点,就算节点内容被删除,节点标签也不会被删除如<span>123</span>
,选中 123,删除后,span 标签还在,除非选中区域包含了标签。 -
selection.extend(node, offset)
: 修改选区,以原来选区的 anchorNode 为基础,设置 focusNode,达到修改选区的效果。 -
addRange(range)
: 像选区添加一个 Range, 上面使用过了 -
getRangeAt(index)
: 获取Range,通过 rangeCount 可以获取到当前选区有多少个范围,通过当前方法可以获取对应的 Range 对象。 -
removeAllRanges()
:删除所有的选区,上面使用过,在 chrome 中,因为最多只能有一个 Range, 所以在调用 addRange 之前,如果已经存在 Range,需要调用该方法移除选区所有 Range -
removeRange(range)
: 移除指定 Range, 一般使用上方移除所有,原因同上 -
selectAllChildren(parentNode)
: 选中 parentNode 的所有子元素,parentNode 本身不会被选中,parentNode为TextNode是无效的 -
sel.setBaseAndExtent(anchorNode,anchorOffset,focusNode,focusOffset)
: 指定 anchorNode 与 focusNode 以及对应的offset设置选区,需要注意的是,anchorNode 或 focusNode 如果不是文本节点,则 offset 值子节点的个数,如果是文本节点,则 offset 指是文本字符个数 -
Modify: 非标语法,可自行查阅文档,不建议使用 developer.mozilla.org/zh-CN/docs/…
3. 详细聊聊 Selection 中的 Range
通过上面的内容,想必看到此处的你对 Selection 与 Range 的区别已经有了很明确的认知,并且会删除原有 Ranges,创建并增加新的 Range 来改变浏览器中被选中的内容。现在我们需要详细看看 Range 提供了哪些方法,方便我们完全掌控选区。
Range 的创建是通过 document 上的 createRange 创建的,创建之后,通过 range 的方法对 range 对象进行设置。
const range = document.createRange()
3.1 Range 中有哪些属性
range.collapsed
: 当前选中的范围是否是重叠,即光标位置,与selection.isCollapsed
作用一致,但 selection 中的选项查看的是页面实时的光标状态,而 range 有可能只是新创建的,或者从 selection 中提取出来的旧的属性,不一定能直接代表当前状态,但可以代表之前或未来的状态。想要将光标移动到某个地方,除了之前介绍的selection.collapse()
方法,还能通过增加一个范围重叠的 Range 来达到目的。range.startContainer
、range.endContainer
、range.startOffset
、range.endOffset
:当前选择范围开始以及结束的的节点容器,这里注意,如下图所示,开始结束位置位于 TextNode 文本节点中时,对应的 container 不是父节点,而是当前的文本节点。startOffset 与 endOffset 与之前计算 selection 的 offset 方式相同。这里与 selection 的anchorNode,focusNode 不同的是,它不关心方向,方向始终是左侧是start, 右侧是 end。range.commonAncestorContainer
: 上面 startContainer 与 endContainer 的公共父节点,如上图,则指代最外层的 p 节点。
3.2 Range 中的主要方法
上面也强调过,Range 是选区中一段段被选中的文本部分,虽然它在 Chrome 中最多只有一个。在使用代码修改选中内容时,可以通过之前介绍的 selection.collapse(), selection.extend(), 或 selection.sel.setBaseAndExtent()
等 api 来控制选区变化,也可以通过修改 range 来控所选的范围,因为它才是选区中真正被选到的那一段。
创建或更新 range
range.cloneRange()
: 创建一个 range 副本,与原来的r ange 互不相关(深拷贝)range.collapse(toStart: boolean)
: 设置光标到当前选择范围的端点(相当于设置光标位置,设置之后就没有范围了),默认光标设置到尾部,toStart 为 true,则设置到首部。range.insertNode(newNode)
: 向 range 首部插入新的节点range.selectNode(referenceNode)
: 上面已经用过了,可以选中对应的referenceNode,docunent.createRange() 创建的内容是空的,可以通过当前 api 或选中页面中的某些文本或者元素,不过要注意插入后,startContainer 与 endContainer 是所选元素的父元素。range.selectNodeContents(referenceNode)
: 选择某个节点的所有子节点,与range.selectNode相比,它选中后,startContainer 与 endContainer 不同,会影响最后 selection 的 anchorNode 与 focusNoderange.setStart(startNode, startOffset)
与range.setEnd(endNode, endOffset)
: 如其名,设置 range 的起始节点与偏移量range.setStartBefore(refenceNode, offset)
、range.setStartAfter(refenceNode, offset)
、range.setEndBefore(refenceNode, offset)
、range.setEndAfter(refenceNode, offset)
: 分别用于设置 start 与 end 位置,Before 与 After 指的是相对于refenceNode 节前面或后面的位置,设置偏移量 offset.range.surroundContents(newParent)
: 用一个新节点包裹当前选区,并将选区起始位置至于新节点标签的内部前后位置,如果当前选区是跨节点的,例如<span>123</span><span>456</span>
从 3 选到 5,此时设置会报错。
查看或比较 range 区域或内容节点等信息
其他的 api 有些用于比较 Range 边界,有些用户获取 Range 内的 dom 元素,意识大小信息等,不常用,使用时参考 api文档,也没几个了。
5. 小结
本文介绍了富文本编辑器核心概念 Selection 与 Range 的使用,并过了一遍 api,其中经常让人琢磨不透的是 Range 是什么,Selection 又是什么。其实 Selection 在页面上是看不见的,没有完全对应的视图,Range 是具体浏览器中一段一段的选中框,在chrome中最多只能选中一段,因此 selection 中最多只有一个 Range,firefox 中可以存在多个Range,按住 ctrl(widnow) 或 cmd(macOS) 后拖拽鼠标即可选中多端。
在设置选区范围时,要注意最好使用 TextNode 文本节点进行设置,因为文本节点对应的每个 offset 都是一个文本字符,其他节点中 一个 offset 会跨越一整个子节点,如果没有完全理解,会导致选中的范围不能达到预期,甚至会有报错。
See you next time!