光标与选区
光标是一种特殊的选区。
Selection
对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。通常由用户拖拽鼠标经过文字而产生。Range
对象表示包含节点和部分文本节点的文档片段。通过selection
对象获得的range
对象才是我们操作光标的重点。
const selection = window.getSelection();
通常情况下我们不会直接操作 selection
对象,而是需要操作用 seleciton
对象所对应的用户选择的 range
。
const range = selection.getRangeAt(0);
可能存在多个选区,目前只有 Firefox 支持多选区。大部分情况下都不需要考虑多选区的情况。
Range 对象上有一个属性,collapsed
,表示起点和终点是否重叠,当 collapsed
为 true
时,选中区域被压缩成一个点,对于普通的元素,可能什么都看不到,如果是在可编辑元素上,那这个被压缩的点就变成了可以闪烁的光标。
可编辑元素
选区和元素是否可编辑并没有直接关系,唯一的区别就是,在可编辑元素上可以看到光标。
<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]
的位置替换新内容,由于新内容的位置覆盖了之前的选区,原选区也就不存在了,那么替换完之后,选区会选中刚刚插入的新内容。
普通元素的选区操作
需要用到前面提到的 Selection
和 Range
相关方法。
主动选择某区域
需创建一个 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);