93
头图

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

image-20220224202846466

What about the cursor, is that the blinking vertical line?

image-20220224203025183

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:

  1. 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.
  2. Range objects represent document fragments containing nodes and partial text nodes. The selection object obtained through the range object is the focus of our operation of the cursor.

To get selection , you can use the global getSelection method

const selection = window.getSelection();

img

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);

img

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)

img

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()

img

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.

img

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();
}

img

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();
}

img

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();
}

img

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();
}

img

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();
}

img

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

img

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

img

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);
}

img

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 of Text , Comment , or CDATASection , then startOffset refers to the offset in characters from the start node. For other Node 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

img

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

img

We can also get child nodes through childNodes

div.childNodes

img

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

img

Look at the effect of selectNodeContents

img

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);
}

img

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:

img

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);
}

img

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);
}

img

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);
}

img

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]
}

img

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);
}

img

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

img

You can also insert tagged content

btn.onclick = () => {
  const newNode = document.createElement('mark');
  newNode.textContent = '我是新内容' 
  lastRange.deleteContents()
  lastRange.insertNode(newNode)
}

img

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()
}

img

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)
}

img

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.

img

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) 
}

img

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

img

img

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 ❤❤❤


XboxYan
18.2k 声望14.1k 粉丝