Selection and cursor in the Web

Posted by misterfine on Thu, 24 Feb 2022 14:09:45 +0100

In web development, sometimes it is inevitable to deal with "selection" and "cursor", such as selecting highlight, selecting toolbar, manually controlling cursor position and so on. The selection area is the part selected with the mouse, usually blue

What about the cursor? Is it the flashing vertical line?

Warm tip: the article is relatively long. After reading it patiently, you can realize completely independent operation of the selection area and cursor

1, What are "selection" and "cursor"?

Let's start with the conclusion: the cursor is a special selection.

To figure this out, we have to mention two important objects: Section and Range . These two objects have a large number of properties and methods. You can view the official documents in detail. Here is a brief introduction:

  1. The Selection object represents the current position of the text range or caret selected by the user. It represents the text Selection in the page and may span multiple elements. It is usually generated by the user dragging the mouse through the text.
  2. The range object represents a document fragment that contains nodes and some text nodes. The range object obtained through the selection object is the focus of our cursor operation.

Get the selection through the global getSelection method

const selection = window.getSelection();

Generally, we will not directly operate the selection object, but the range selected by the user corresponding to the seleciton object. The acquisition method is as follows:

const range = selection.getRangeAt(0);

Why does getRangeAt need to pass a sequence here? Can there be several constituencies? It's true, but at present, only Firefox supports multiple selection areas. Multiple selection areas can be realized through cmd key (ctrl key on windows)

You can see that the rangeCount returned by the selection is 5. However, in most cases, there is no need to consider the situation of multiple constituencies.

If you want to get the selected text content is also very simple, you can directly toString

window.getSelection().toString()
// perhaps
window.getSelection().getRangeAt(0).toString()

Let's look at an attribute returned by a range, collapsed, which indicates whether the starting point and end point 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.

Therefore, the cursor is a selection with the same starting point

2, Editable element

Although the selection is not directly related to whether the element is editable or not, the only difference is that you can see the cursor on the editable element, but many times the requirements are for editable elements.

There are generally two types of editable elements. One is the default form input box and textarea

<input type="text">
<textarea></textarea>

The other is to add the attribute contentable = "true" or CSS attribute WebKit user modify to the element

<div contenteditable="true">yux Reading front end</div>

perhaps

div{
    -webkit-user-modify: read-write;
}

What's the difference between the two? In short, the form elements are easier to control, and the browser provides a more intuitive API to manipulate the selection.

3, input and textarea selection operations

First, let's look at the operation mode of such elements. There is almost no API related to section and range, which may be better understood. API is not easy to remember. Let's take a few examples directly. Here, take textarea as an example

Suppose the HTML is as follows

<textarea id="txt">Reading under the banner includes QQ Well known brands in the industry such as reading, starting point Chinese network and Xinli media have a reserve of 14.5 million works, 9.4 million creators, covering more than 200 content categories and reaching hundreds of millions of users. They have successfully exported products in animation, film and television, games and other fields, including Qingnian, redundant son-in-law, ghost blowing the lamp, Langya list and full-time Master IP Adaptation of representative works.</textarea>

1. Actively select an area

The selected area of the form element can be used setSelectionRange method

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

There are three parameters: selectionStart, selectionEnd and selectionDirection

For example, if we want to take the initiative to select the first two words "reading", then we can

btn.onclick = () => {
    txt.setSelectionRange(0,2);
    txt.focus();
}

If you want to select all of them, you can directly use the select method

btn.onclick = () => {
    txt.select();
    txt.focus();
}

2. Focus to a certain position

If we want to move the cursor to the back of "reading text", 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); // Set the same starting point
    txt.focus();
}

3. Restore the previous selection

Sometimes, we need to re select the previous constituency after clicking on other places. This requires first recording the starting position of the previous selection, and then actively setting it

The starting position of the selected area can be obtained by using selectionStart and selectionEnd attributes, 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 constituency

The form input box needs to be inserted setRangeText method,

inputElement.setRangeText(replacement);
inputElement.setRangeText(replacement, start, end [, selectMode]);

This method has two forms. The second form has four parameters. The first parameter replacement represents the text to be replaced, and then start and end are the starting positions. By default, it is the current selected area of the element. The last parameter selectMode represents the status of the selected area after replacement. There are four options

  • Select select after replacement
  • After start replacement, the cursor is placed before the replacement word
  • end the cursor after the replacement word
  • preserve default, try to keep the selection

For example, we insert or replace a paragraph of text in a selection“ ❤️❤️❤️”, You can do this:

btn.onclick = () => {
    txt.setRangeText('❤️❤️❤️')
    txt.focus();
}

There is a default value above. What does "try to keep the selection" mean? Assuming that the manually selected area is [9,10], if the new content is replaced in the position of [1,2], the selected area will still be in the previous position. If the new content is replaced at [8,11], the original selection will not exist because the location of the new content covers the previous selection. After the replacement, the selection will select the new content just inserted

btn.onclick = () => {
    txt.setRangeText('❤️❤️❤️',5,10,'preserve')
    txt.focus();
}

The complete code above can be accessed setSelectionRange & setRangeText (codepen.io) , that's all for the operations related to the form input box. The following describes the functions of common elements

4, Common selection element

First of all, ordinary elements do not have the above methods

This requires the aforementioned section and range related methods. There are many API s here. 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 the Section. It should be noted that the method of setting the starting position of the area is range.setStart and range.setEnd

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

Why divide it into two parts? The reason is that the selection of common elements is much more complex than the form! There is only a single text in the form input box, and ordinary elements may contain multiple elements

There are two methods to select the content area before the two

To add to a selection selection.addRange

selection.addRange(range)

However, before adding, you should clear the previous selection, which can be used selection.removeAllRanges method

selection.removeAllRanges()
selection.addRange(range)

Let's look at the plain text example first. Suppose HTML is as follows

<div id="txt" contenteditable="true">Reading under the banner includes QQ Well known brands in the industry such as reading, starting point Chinese network and Xinli media have a reserve of 14.5 million works, 9.4 million creators, covering more than 200 content categories and reaching hundreds of millions of users. They have successfully exported products in animation, film and television, games and other fields, including Qingnian, redundant son-in-law, ghost blowing the lamp, Langya list and full-time Master IP Adaptation of representative works.</div>

If you want to select the first two words "reading", you can do so

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

Note that the nodes set in setStart and setEnd are txt Firstchild, not txt, why?

MDN is defined as follows:

If the start Node type is one of Text, comment, or CDATASection, startOffset refers to the offset of the character calculated from the start Node. For other Node types, startOffset refers to the offset of child nodes calculated from the starting Node.

What do you mean? Suppose there is such a structure:

<div>yux Reading front end</div>

In fact, the structure is like this

Therefore, if the div in the outermost layer is used as the starting node, it has only one text node. If the offset is set to 2, the browser will directly report an error. Since there is only one text node, it needs to take its first text node as the starting node, that is, firstChild, so it will take each character as the offset

2. Actively select an area in rich text

The biggest difference between ordinary elements and form elements is that they support embedded tags, that is, rich text. Suppose such an HTML

<div id="txt" contenteditable="true">yux<span>Reading text</span>front end</div>

The real structure is like this

We can also obtain child nodes through childNodes

div.childNodes

What should I do if I want to select "reading text"?

Since "reading" is an independent label, two new API s can be used, range.selectNode and range.selectNodeContents , both of them indicate that a node is selected. The difference is that selectNodeContents only contains nodes, not itself

The label of "reading" here is the second, 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 differences between selectNodeContents and selectNode. Add a red style to span. Here is the effect of selectNode

Let's look at the effect of selectNodeContents

Obviously, selectNodeContents is only the interior of the selected node. After deletion, the node itself is still there, so the re-entry content is still red.

If you only want to select the word "reading" of "reading", how to operate 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);
}

As you can see, the starting point here is relative to the span element rather than 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 have, just select it directly instead of manually looking for specific labels like this. What should we do?

The key point of the selection is to obtain the start point, end 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 little complicated

<div>yux<span>Reading text<strong>front end</strong>team</span></div>

I tried to find many official documents. Unfortunately, I didn't get the API directly, so I had to traverse layer by layer. The overall idea is to obtain the information of the first layer through childNodes and divide it into several intervals. If the required offset is in this interval, continue to traverse inward until the bottom layer, as shown below:

Just look at the red part (#text), isn't it clear at a glance? Code implementation 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)
            }
        })
    }
    // Recursive traversal to extract all #text
    map(wrap_dom.childNodes);
    // Calculate the position range of text [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
    },[])
    // Find the range that meets the conditions
    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 structure it is

<div id="txt" contenteditable="true">Yuewen banner<span>Include <span><strong>QQ</strong>read</span>,Starting point Chinese network, Xinli media and other well-known brands in the industry</span>,It has a reserve of 14.5 million works and 9.4 million<span>Creator</span>,Covering more than 200 content categories and reaching hundreds of millions of users, it has successfully exported products in animation, film and television, games and other fields, including "Qingnian", "redundant son-in-law", "ghost blowing the lamp", "Langya list" and "Full-time Master" IP Adaptation of representative works.</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 to a certain position

This is easier. You just need to set the starting point the same. For example, you want to move the cursor behind "QQ" and the position after "QQ" is "8", so you can do 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. First, you can save the previous constituency and then restore it later

let lastRange = null;
txt.onkeyup = function (e) {
    var selection = document.getSelection()
    // Save the last range object
    lastRange = selection.getRangeAt(0)
}
btn.onclick = () => {
  const selection = document.getSelection();
  selection.removeAllRanges();
  // Restore last selection
  selection.addRange(lastRange);
}

However, this method is not reliable. The saved lastRange is easy to lose, because it follows the content. If the content changes, the selection will no longer exist. Therefore, a more reliable method is needed, such as recording the absolute offset before. It also needs to traverse before to find the lowest text node, Then calculate the offset relative to the whole text, and 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)
            }
        })
    }
    // Recursive traversal to extract all #text
    map(wrap_dom.childNodes);
    // Calculate the position range of text [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);
    // Match the #text between the selected area and the interval to calculate the overall offset
    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 designated constituency

Insert content in the selection, you can use range.insertNode Method, which means that inserting a node at the starting point of the selection will not replace the currently selected one. If you want to replace it, you can delete it first. It needs to be deleted deleteContents Method, the specific implementation is

let lastRange = null;
txt.onmouseup = function (e) {
    lastRange = window.getSelection().getRangeAt(0);
}
btn.onclick = () => {
  const newNode = document.createTextNode('I'm new')
  lastRange.deleteContents()
  lastRange.insertNode(newNode)
}

It should be noted here that it must be a node. If it is text, you can use document CreateTextNode to create

You can also insert tagged content

btn.onclick = () => {
  const newNode = document.createElement('mark');
  newNode.textContent = 'I'm new' 
  lastRange.deleteContents()
  lastRange.insertNode(newNode)
}

The new content to be inserted is selected by default. What if you want the cursor to be behind the new content after insertion

This can be used range.setStartAfter The starting point of the element is the default processing point of the element, which is set later

btn.onclick = () => {
  const newNode = document.createElement('mark');
  newNode.textContent = 'I'm new' 
  lastRange.deleteContents()
  lastRange.insertNode(newNode)
  lastRange.setStartAfter(newNode)
  txt.focus()
}

6. Label the package for the designated constituency

Finally, let's take a more common example. When selected, wrap the selected area with a layer of labels.

This is supported by the official API and needs to be used range.surroundContents Method to wrap a label around the selection

btn.onclick = () => {
  const mark = document.createElement('mark');
  lastRange.surroundContents(mark)
}

However, this method has a defect. When there is a "fault" in the elected area, for example, an error will be reported directly

You can use another way to avoid this problem, which is similar to the principle of replacing content above, but you need to obtain the content of the selection first. You can obtain the content of the selection through range.extractContents Method, which returns a DocumentFragment Object, add the selected area content to the new node, and then insert the new content. The specific implementation is as follows

btn.onclick = () => {
    const mark = document.createElement('mark');
  // Record the content of the selected area
  mark.append(lastRange.extractContents())
  lastRange.insertNode(mark) 
}

The complete code above can be accessed Section & Range (codepen.io)

5, Summarize with two pictures

If you fully master these methods, I believe you can handle the selection easily. Remember that the cursor is a special selection and has nothing to do with whether the elements are focused. Then there are various API s. Here are two diagrams to show the general relationship

With the popularity of vue and react frameworks, these native API s may be rarely mentioned. Most functional frameworks have been encapsulated for us, but there are always some functions that are not satisfied, so we must rely on the "native power". Finally, if you think it's good and helpful to you, you're welcome to like, collect and forward ❤❤❤

Topics: Javascript Front-end html5 html DOM