跳到主要内容

Selection与Range

· 阅读需 8 分钟
熊滔

光标与选区

光标是一种特殊的选区。

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

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

const range = selection.getRangeAt(0);

可能存在多个选区,目前只有 Firefox 支持多选区。大部分情况下都不需要考虑多选区的情况。

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

可编辑元素

选区和元素是否可编辑并没有直接关系,唯一的区别就是,在可编辑元素上可以看到光标。

<input type="text">
<textarea></textarea>
<div contenteditable="true"></div>
div{
-webkit-user-modify: read-write;
}

这两种有什么区别呢?简单来说,表单元素更容易控制,浏览器提供了更直观的 API 来操控选区。

input 与 textarea

inputElement.setSelectionRange(selectionStart, selectionEnd [, selectionDirection]);

选择全部

txt.select();
txt.focus();

替换内容

inputElement.setRangeText(replacement);
inputElement.setRangeText(replacement, start, end [, selectMode]);
  • replacement:替换的文本
  • start:起始位置
  • end:终止位置
  • selectMode:替换后的选取状态
    • preserve:默认值,保留选区

假设手动选中的区域是 [9,10],如果在 [1,2] 的位置替换新内容,那么选区仍然在之前位置。如果在 [8,11] 的位置替换新内容,由于新内容的位置覆盖了之前的选区,原选区也就不存在了,那么替换完之后,选区会选中刚刚插入的新内容。

普通元素的选区操作

需要用到前面提到的 SelectionRange 相关方法。

主动选择某区域

需创建一个 Range 对象,设置范围,然后添加到 Selection 对象中

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

因为普通元素可能包含多个元素,所以选区创建比表单复杂。

// 移除所有选区
selection.removeAllRanges()
// 添加选区
selection.addRange(range)

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

选择节点

// 选中节点内容,并选中标签
range.selectNode();
// 只选中节点内的Text内容,不选中标签
range.selectNodeContents();
有点像 innerHTML 与 outerHTML 的关系。

直接根据父元素选择文本

如何通过相对外层的偏移量获取到最里层元素的信息呢?整体思路就是深度优先遍历标签,取得所有的 TextNode,并得到每个 TextNode 所在的区间。然后找到起始点和终止点所在的区间,返回该区间的 TextNode 以及相应的偏移量。

const getNodeAndOffset = (ele, start = 0, end = 0) => {
const txtList = [];
const dfs = (childNodes) => {
[...childNodes].forEach(node => {
if (node.nodeName === '#text') {
txtList.push(node);
} else {
dfs(node.childNodes);
}
})
}
dfs(ele.childNodes);
const clips = txtList.reduce((arr, el, index) => {
const endPosition = el.textContent.length + (index > 0 ? arr[index - 1][2] : 0);
const startPosition = endPosition - el.textContent.length;
arr.push([el, startPosition, endPosition]);
return arr;
}, []);

const startNode = clips.find(item => start >= item[1] && start < item[2]);
// 如果 end 超过了长度要不要直接设置为末尾
const endNode = clips.find(item => end >= item[1] && end < item[2]);
// startNode 和 endNode 没有找到怎么办
return [startNode[0], start - startNode[1], endNode[0], end - endNode[1]];
}

还原选区

<div id="txt" contenteditable="true">
天不生夫子,万古如长夜!
</div>
<button id="restore">还原选区</button>

方案一,保存 Range 对象,然后恢复,但是当内容发生变化时,原先的选区就失效了

let lastRange = null;
txt.addEventListener('mouseup', () => {
const selection = document.getSelection();
lastRange = selection.getRangeAt(0);
})
const restore = document.getElementById('restore');
restore.addEventListener('click', () => {
const selection = document.getSelection();
selection.removeAllRanges();
selection.addRange(lastRange);
});

方案二,获得绝对偏移量,即使内容发生改变,也不影响。获得绝对偏移量的思路还是先获得所有文本元素的区间,然后找到被选中的起始节点和结束节点,获得其起始偏移和结束偏移,分别加上节点所在区间的起始值即可。

const getRangeOffset = ele => {
const txtList = [];
const dfs = (childNodes) => {
[...childNodes].forEach(node => {
if (node.nodeName === '#text') {
txtList.push(node);
} else {
dfs(node.childNodes);
}
})
}
dfs(ele.childNodes);
const clips = txtList.reduce((arr, el, index) => {
const endPosition = el.textContent.length + (index > 0 ? arr[index - 1][2] : 0);
const startPosition = endPosition - el.textContent.length;
arr.push([el, startPosition, endPosition]);
return arr;
}, []);
const selection = document.getSelection();
const range = selection.getRangeAt(0);
const startNode = clips.find(item => range.startContainer === item[0]);
const endNode = clips.find(item => range.endContainer === item[0]);
return [startNode[1] + range.startOffset, endNode[1] + range.endOffset];
}
let lastRange = {};
txt.addEventListener('mouseup', () => {
const [startOffset, endOffset] = getRangeOffset(txt);
lastRange = {
start: startOffset,
end: endOffset
}
})
const restore = document.getElementById('restore');
restore.addEventListener('click', () => {
const selection = document.getSelection();
const range = document.createRange();
const [startNode, startPosition, endNode, endPosition] = getNodeAndOffset(txt, lastRange.start, lastRange.end);
range.setStart(startNode, startPosition);
range.setEnd(endNode, endPosition);
selection.removeAllRanges();
selection.addRange(range);
});

插入内容

range.insertNode() 既可以插入文本内容,也可以插入标签。它不会替换已选择的内容,而是在起点处插入一个节点。如果要替换,可以先删除在添加,通过 range.deleteContents 进行删除。

const textNode = document.createTextNode('新内容');
range.deleteContents();
range.insertNode(textNode);
const mark = document.createElement('mark');
mark.textContent = '新内容';
range.insertNode(mark);

给选中内容包裹标签

range.surroundContents 给选区包裹一层标签

const mark = document.createElement('mark');
range.surroundContents(mark);

但是当选区横跨多个标签时,就会发生错误。另一种方案,通过 range.extractContents 提取选区内容,是一个 DocumentFragment,然后插入新内容

const mark = document.createElement('mark');
mark.append(range.extractContents());
range.insertNode(mark);