In web development, sometimes it is unavoidable to deal with "selection" and "cursor", such as highlighting, selecting a toolbar, and manually controlling the cursor position. The selection is the part selected with the mouse, usually blue
What about the cursor, is that the blinking vertical line?
Reminder: The article is relatively long, and you can operate the selection area and cursor completely independently after reading it patiently.
1. What are "selection" and "cursor"?
Let's talk about the conclusion first: cursor is a special constituency .
To figure this out, two important objects have to be mentioned: Selection and Range . These two objects have a large number of properties and methods. For details, you can check the official documentation. Here is a brief introduction:
- Selection object represents the text range selected by the user or the current position of the caret. It represents a selection of text in the page, which may span multiple elements. Usually generated by the user dragging the mouse over text.
- Range objects represent document fragments containing nodes and partial text nodes. The
selection
object obtained through therange
object is the focus of our operation of the cursor.
To get selection
, you can use the global getSelection method
const selection = window.getSelection();
Normally, we do not directly operate selection
object, but need to operate the range
selected by the user corresponding to the seleciton
object. The way to get it is as follows:
const range = selection.getRangeAt(0);
Why does getRangeAt
need to pass a sequence here, can there still be several constituencies? It's true, but only Firefox currently supports multiple selections, and multiple selections can be achieved through the cmd
key ( ctrl
key on Windows)
It can be seen that selection
returned by rangeCount
is 5 at this time. However, in most cases there is no need to consider the case of multiple constituencies.
If you want to get the selected text content, it is very simple, toString
window.getSelection().toString()
// 或者
window.getSelection().getRangeAt(0).toString()
Look at an attribute returned by range
, collapsed
, which indicates whether the start and end points of the selection overlap. When collapsed
is true
, the selected area is compressed into a point. For ordinary elements, you may not see anything. If it is on an editable element, the compressed point becomes a flashing cursor.
So, the cursor is a selection with the same starting point
2. Editable elements
Although the selection is not directly related to whether the element is editable, the only difference is that the cursor can be seen on the editable element, but many times the requirements are for the editable element.
When it comes to editable elements, there are generally two types, one is the default form input box input
and textarea
<input type="text">
<textarea></textarea>
The other is to add the attribute contenteditable="true"
to the element, or the CSS attribute -webkit-user-modify
<div contenteditable="true">yux阅文前端</div>
or
div{
-webkit-user-modify: read-write;
}
What's the difference between the two? Simply put, form elements are easier to control, and browsers provide a more intuitive API to manipulate selections.
3. Input and textarea selection operations
First of all, look at the operation mode of this type of element. You can almost understand the APIs related to section
and range
without using it. API is not very easy to remember, just look at a few examples, here is textarea
as an example
Suppose the HTML is as follows
<textarea id="txt">阅文旗下囊括 QQ 阅读、起点中文网、新丽传媒等业界知名品牌,拥有 1450 万部作品储备,940 万名创作者,覆盖 200 多种内容品类,触达数亿用户,已成功输出包括《庆余年》《赘婿》《鬼吹灯》《琅琊榜》《全职高手》在内的动画、影视、游戏等领域的 IP 改编代表作。</textarea>
1. Actively select an area
The selected area of the form element can use setSelectionRange method
inputElement.setSelectionRange(selectionStart, selectionEnd [, selectionDirection]);
There are 3 parameters which are selectionStart
(start position), selectionEnd
(end position) and selectionDirection
(direction)
For example, if we want to actively select the first two words "reading", then we can
btn.onclick = () => {
txt.setSelectionRange(0,2);
txt.focus();
}
If you want to select all, you can use the select
method directly
btn.onclick = () => {
txt.select();
txt.focus();
}
2. Focus on a location
If we want to move the cursor to the back of "Reading", according to the above, the cursor is actually the product of the same starting position of the selection, so we can do this
btn.onclick = () => {
txt.setSelectionRange(2,2); // 设置起始点相同
txt.focus();
}
3. Restore the previous selection
Sometimes, we need to reselect the previous selection after clicking elsewhere. This needs to record the starting position of the previous constituency first, and then set it automatically.
The starting position of the constituency can be obtained by using the two attributes selectionStart
and selectionEnd
, so
const pos = {}
document.onmouseup = (ev) => {
pos.start = txt.selectionStart;
pos.end = txt.selectionEnd;
}
btn.onclick = () => {
txt.setSelectionRange(pos.start,pos.end)
txt.focus();
}
4. Insert (replace) content in the specified selection
setRangeText method needs to be used to insert the content of the form input box.
inputElement.setRangeText(replacement);
inputElement.setRangeText(replacement, start, end [, selectMode]);
This method has two forms. The second form has 4 parameters. The first parameter is replacement
, which indicates the text to be replaced. Then start
and end
are the starting positions. The default is the currently selected area of the element. The last parameter is selectMode
. Indicates the state of the selection after replacement, there are 4 options
- select after replacement
- start after the replacement, the cursor is before the replacement word
- end After the replacement, the cursor is after the replacement word
- preserve default, try to preserve the selection
For example, we insert or replace a piece of text "❤️❤️❤️" in the selection, we can do this:
btn.onclick = () => {
txt.setRangeText('❤️❤️❤️')
txt.focus();
}
What does it mean that there is a default value "try to keep the selection"? Assuming that the manually selected area is [9,10]
, if you replace the new content in the position of [1,2]
, the selection area is still in the previous position. If you replace the new content at the position of [8,11]
, because the position of the new content covers the previous selection area, the original selection area does not exist, then after the replacement, the selection area will select the new content just inserted
btn.onclick = () => {
txt.setRangeText('❤️❤️❤️',5,10,'preserve')
txt.focus();
}
The above complete code can be accessed at setSelectionRange & setRangeText (codepen.io) , the relevant operations of the form input box are here, the following describes the common elements
Fourth, the selection operation of common elements
First of all, ordinary elements do not have the above methods
This requires the use of the aforementioned section
and range
related methods. There are many APIs here, so let’s start with an example.
1. Actively select an area
First, you need to actively create a Range
object, then set the starting position of the area, and then add this object to Section
. It is worth noting that the methods to set the starting position of the range are range.setStart and range.setEnd
range.setStart(startNode, startOffset);
range.setEnd(endtNode, endOffset);
Why is it divided into two parts? reason is that the selection of ordinary elements is much more complicated than the form! There is only a single text in the form input box, ordinary elements may contain multiple elements
Through two methods, the content area before the two can be selected
The method to add to the selection is selection.addRange
selection.addRange(range)
But generally before adding, you should clear the previous selection, you can use the selection.removeAllRanges method
selection.removeAllRanges()
selection.addRange(range)
Let's look at the plain text example first, assuming the HTML is as follows
<div id="txt" contenteditable="true">阅文旗下囊括 QQ 阅读、起点中文网、新丽传媒等业界知名品牌,拥有 1450 万部作品储备,940 万名创作者,覆盖 200 多种内容品类,触达数亿用户,已成功输出包括《庆余年》《赘婿》《鬼吹灯》《琅琊榜》《全职高手》在内的动画、影视、游戏等领域的 IP 改编代表作。</div>
If you want to select the first two words "Reading", you can do this
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);
}
One thing to note here, the nodes set in setStart
and setEnd
are txt.firstChild
, not txt
, why?
It's defined on MDN as follows:
If the start node type is one ofText
,Comment
, orCDATASection
, thenstartOffset
refers to the offset in characters from the start node. For otherNode
type nodes,startOffset
refers to the offset of the child node from the start node.
What does that mean? Suppose there is a structure like this:
<div>yux阅文前端</div>
Actually the structure is like this
So if the outermost div
is used as the starting node, then for itself, has only 1 text node If the offset is set to 2, the browser will report an error directly. Since there is only one text node, it needs to be Start with its first text node, which is firstChild
, so it will have an offset of for each character
2. Actively select an area in the rich text
The biggest difference between ordinary elements and form elements is that they support inline tags, that is, rich text. Assuming such an HTML
<div id="txt" contenteditable="true">yux<span>阅文</span>前端</div>
The real structure is like this
We can also get child nodes through childNodes
div.childNodes
What should I do if I want to select "Reading"?
Since "Reading" is an independent tag, two other new APIs can be used, range.selectNode and range.selectNodeContents , both of which indicate that a certain node is selected, the difference is that selectNodeContents
only Contains only nodes, not itself
Here the label where "reading" is located is the second one, so
btn.onclick = () => {
const selection = document.getSelection();
const range = document.createRange();
range.selectNode(txt.childNodes[1])
selection.removeAllRanges();
selection.addRange(range);
}
Here you can see the specific difference between selectNodeContents
and selectNode
, add a red style to span
, the following is the effect of selectNode
Look at the effect of selectNodeContents
Obviously selectNodeContents
is only the inside of the selected node. When deleted, the node itself is still there, so the re-entered content is still red.
If you only want to select the word "read" of "reading", how to do it? In fact, just look down under this label.
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);
}
It can be seen that the starting points here are all relative to the span
element, not the outer div
, which seems unreasonable? Usually what we want is to specify an interval for the outermost layer, such as [2,5]
, no matter what structure you are, just select it directly, instead of manually looking for specific labels like this, how to deal with this?
The most critical point of selection is to obtain the starting point, ending point and offset. How to obtain the information of the innermost element through the offset relative to the outer layer?
Suppose there is such a piece of HTML, which is a bit complicated
<div>yux<span>阅文<strong>前端</strong>团队</span></div>
I tried to find a lot of official documents, but unfortunately there is no API for direct access, so I can only traverse it layer by layer. The overall idea is to first obtain the information of the first layer through childNodes
, which is divided into several intervals. If the required offset is in this interval, continue to traverse it until the bottom layer, as shown below:
Just look at the red part (#text), don't you see it at a glance? Implementing it in code is
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]]
}
With this method, you can select any interval, no matter what the structure is
<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. Focus on a location
This is easier, you just need to set the same starting point. For example, if you want to move the cursor to the back of "QQ", the position after "QQ" is "8", so you can do it like this
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. Restore the previous selection
There are two ways to do this. The first is to save the previous constituency first, and then restore it later.
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);
}
But this method is not very reliable, the saved lastRange
is easy to lose, because it follows the content, if the content changes, the constituency will not exist, so a more reliable method is needed, such as recording The previous absolute offset also requires the previous traversal to find the bottommost text node, and then calculate the offset relative to the entire text. The code is as follows:
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]
}
Then you can use this offset to actively select the area
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. Insert (replace) content in the specified selection
To insert content in the selection, you can use range.insertNode method, which means inserting a node at the starting point of the selection. will not replace the currently selected . If you want to replace it, you can delete it first. deleteContents method, the specific implementation is
let lastRange = null;
txt.onmouseup = function (e) {
lastRange = window.getSelection().getRangeAt(0);
}
btn.onclick = () => {
const newNode = document.createTextNode('我是新内容')
lastRange.deleteContents()
lastRange.insertNode(newNode)
}
It should be noted here that it must be a node. If it is text, it can be created with document.createTextNode
You can also insert tagged content
btn.onclick = () => {
const newNode = document.createElement('mark');
newNode.textContent = '我是新内容'
lastRange.deleteContents()
lastRange.insertNode(newNode)
}
The inserted new content is selected by default. If you want the cursor to be behind the new content after insertion, what should you do?
At this time, you can use range.setStartAfter method, which means that the starting point of the set range is after the element, and the end point is after the element by default.
btn.onclick = () => {
const newNode = document.createElement('mark');
newNode.textContent = '我是新内容'
lastRange.deleteContents()
lastRange.insertNode(newNode)
lastRange.setStartAfter(newNode)
txt.focus()
}
6. Wrap the label to the specified selection
Finally, let's look at a more common example, wrapping the selected area with a layer of labels when selected.
This is supported by the official API, you need to use range.surroundContents method, which means wrapping a layer of labels for the selection area
btn.onclick = () => {
const mark = document.createElement('mark');
lastRange.surroundContents(mark)
}
However, this method has a flaw. When there is a "fault" in the selected area, such as this case, an error will be reported directly.
There is another way to avoid this problem, which is similar to the principle of replacing content above, but you need to get the content of the selection first. You can get the content of the selection through the range.extractContents method, which returns a DocumentFragment object. Add the selection content to the new node, and then insert the new content, the specific implementation is as follows
btn.onclick = () => {
const mark = document.createElement('mark');
// 记录选区内容
mark.append(lastRange.extractContents())
lastRange.insertNode(mark)
}
The complete code above can be accessed at Section & Range (codepen.io)
5. Summarize with two pictures
If you fully master these methods, I believe that you can handle the selection area with ease. Remember that the cursor is a special selection area, and it has nothing to do with whether the element is focused, and then there are various APIs. Here are two diagrams. relation
The above APIs are not comprehensive, but cover most of the scenarios in normal development. If you want to know more comprehensive properties and methods, you can check them on MDN.
With the popularity of frameworks such as vue and react, these native APIs may be rarely mentioned. Most of the functional frameworks help us encapsulate them, but there are always some functions that are not satisfied, which must be done with the help of " original power". Finally, if you think it's good and helpful to you, please like, bookmark, and forward ❤❤❤
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。