In Web development, it is unavoidable to deal with “selection” and “cursor” at some point, such as highlight selection, toolbar selection, manual cursor position control, etc. A selection is the part selected with the mouse, usually in blue

What about the cursor? Is that the blinking vertical line?

Tips: The article is quite long, and you can operate the selection and cursor independently after reading it patiently

What are “selection” and “cursor”?

Conclusion first: a cursor is a special selection.

To make sense of this, we have to mention two important objects: Section and Range. Both objects have a large number of properties and methods, which can be found in the official documentation.

  1. The Selection object represents the range of text or the current position of the caret selected by the user. It represents a text selection on a page that may span multiple elements. It is usually created by the user dragging the mouse over text.
  2. RangeObject represents a document fragment that contains nodes and partial text nodes. throughselectionObject acquiredrangeObjects are the focus of cursor manipulation.

Selection can be obtained using the global getSelection method

const selection = window.getSelection();
Copy the code

Normally we don’t operate on selection directly, but rather on the user-selected range that the seleciton object corresponds to. Obtain it as follows:

const range = selection.getRangeAt(0);
Copy the code

Why does getRangeAt need to pass a sequence? Can there be more than one selection? That’s true, but currently only Firefox supports multi-selection, which can be done with the CMD key (CTRL on Windows)

As you can see, Selection returns a rangeCount of 5. But for the most part, you don’t have to worry about multiple constituencies.

If you want to get the selected text content, it’s very easy, just toString

window.getSelection().toString()
/ / or
window.getSelection().getRangeAt(0).toString()
Copy the code

And an attribute returned from a range, collapsed, indicates whether the start and end of a selection overlap. During collapsed true, the selected area compreses into a point that may not be visible for ordinary elements but becomes a blinking cursor for editable elements.

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

Editable elements

Although selection is not directly related to whether an element is editable, the only difference is that you can see the cursor over the editable element, many times the need is for editable elements.

When it comes to editable elements, there are generally two types: the default form input fields input and Textarea

<input type="text">
<textarea></textarea>
Copy the code

Another option is to add elements with the property contenteditable=”true” or the CSS property -webkit-user-modify

<div contenteditable="true">Yux reading front end</div>
Copy the code

or

div{
    -webkit-user-modify: read-write;
}
Copy the code

What’s the difference between the two? Simply put, form elements are easier to control, and browsers provide more intuitive apis for manipulating selection.

Select input and Textarea

First of all, if you look at how these elements operate, you can almost do without the section and range apis, so it’s probably a little bit easier to understand. It’s a little bit hard to remember, so let’s just look at a couple of examples, let’s take a textarea for example

Suppose the HTML looks like this

<textarea id="txt">With well-known brands in the industry such as QQ Reading, Qidian.com and Xinli Media, China Literature has a reserve of 14.5 million works and 9.4 million creators, covering more than 200 content categories and reaching hundreds of millions of users. It has successfully exported representative WORKS of IP adaptation in the fields of animation, film and television, and games, including "Celebration of Life", "Redundant Husband", "Ghost Blows out the Lamp", "Nirvana in Fire", and "The Master of Full-time".</textarea>
Copy the code

1. Proactively select an area

You can use the setSelectionRange method for the selected region of a form element

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

There are three parameters: selectionStart, selectionEnd, and selectionDirection

For example, we want to take the initiative to select the first two words “read”, so can

btn.onclick = () = > {
    txt.setSelectionRange(0.2);
    txt.focus();
}
Copy the code

If you want to select all of them, you can just use select

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

2. Focus in one place

If we want to move the cursor to the end of “read text”, the cursor is actually the product of the same selection starting position, so we can do this

Btn.onclick = () => {txt.setselectionrange (2,2); // set the start point to the same txt.focus(); }Copy the code

3. Restore the previous selection

Sometimes we need to re-select the selection after clicking somewhere else. To do this, you need to record the starting position of the previous selection, and then actively set it

The starting position of the selection can be gotten by 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();
}
Copy the code

4. Insert (replace) the content in the specified selection

The setRangeText method is used to insert content into the form input field,

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

This method takes two forms. The second form takes four parameters. The first parameter replacement represents the text to be replaced, followed by the start and end positions, which default to the currently selected region of the element, and the last parameter selectMode, which represents the state of the selection after the replacement

  • Select Select after the replacement
  • Start The cursor is placed before the replacement word
  • The cursor is placed after the replacement word
  • Preserve the default value to try to preserve the selection

For example, we could insert or replace the selection with a text “❤️❤️❤️” like this:

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

What does it mean that there is a default value “try to preserve the selection”? Assuming that the manually selected field is [9,10], if the new content is replaced at [1,2], the selection remains in the previous position. If you replace the new content at [8,11], the new content will be selected because the new content overwrites the previous selection and the original selection will no longer exist

btn.onclick = () = > {
    txt.setRangeText('❤ ️ ❤ ️ ❤ ️'.5.10.'preserve')
    txt.focus();
}
Copy the code

SetSelectionRange & setRangeText (codepen.io)

4. Selection of ordinary elements

First, there are no such methods for ordinary elements

So that’s where the section and range methods come in, and there’s a lot of APIS, so let’s start with examples

1. Proactively select an area

You create a Range object, set the starting position of the Range, and add the object to the Section. Note that the methods for setting the starting position of the range are range.setstart and range.setend

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

Why split it into two parts? The reason is that selection for normal elements is much more complex than selection for forms! The form input field has a single text, and a normal element may contain multiple elements

There are two ways to select the content area that precedes them

The method to add to a selection is selection. AddRange

selection.addRange(range)

General before adding, however, should be removed before the district, can use selection. RemoveAllRanges method

selection.removeAllRanges()
selection.addRange(range)
Copy the code

Let’s look at the plain text example, assuming the following HTML

<div id="txt" contenteditable="true">With well-known brands in the industry such as QQ Reading, Qidian.com and Xinli Media, China Literature has a reserve of 14.5 million works and 9.4 million creators, covering more than 200 content categories and reaching hundreds of millions of users. It has successfully exported representative WORKS of IP adaptation in the fields of animation, film and television, and games, including "Celebration of Life", "Redundant Husband", "Ghost Blows out the Lamp", "Nirvana in Fire", and "The Master of Full-time".</div>
Copy the code

If you want to check the first two words “read”, 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);
}
Copy the code

SetStart and setEnd use txt.firstChild instead of TXT.

MDN is defined as follows:

If the start node type is one of Text, Comment, or CDATASection, then startOffset refers to the offset of the character from the start node. For other Node types, startOffset is the offset from the start Node to the child Node.

What does that mean? Suppose we have a structure like this:

<div> Yux </div>Copy the code

Here’s what it looks like

So if you use the outermost div as the start node, it will have only one text node for itself. If you set the offset to 2, the browser will report an error. Since there is only one text node, it needs its first text node as the start node, which is firstChild. Then it will take each character as the offset

2. Proactively select an area in the rich text

The big difference between a normal element and a form element is that it supports inline tags, rich text, let’s say an HTML like this

<div id="txt" contenteditable="true">yux<span>Read the article</span>The front end</div>
Copy the code

The actual structure looks like this

We can also get childNodes via childNodes

div.childNodes
Copy the code

What should I do if I want to select “Read articles”?

Since Reading text is a separate label, there are two new apis available, range.selectNode and range.selectNodeContents, both of which indicate that a node is selected. SelectNodeContents, however, contain only nodes. Does not contain itself

Here, the “read text” tag is number two, so

btn.onclick = () => {
  const selection = document.getSelection();
  const range = document.createRange();
  range.selectNode(txt.childNodes[1])
  selection.removeAllRanges();
  selection.addRange(range);
}
Copy the code

To see the difference between selectNodeContents and SelectNodes, add a red style to a span. Here’s how a selectNode looks

Now look at the effect of selectNodeContents

It’s clear that selectNodeContents are just inside the selected node, and when deleted, the node itself will still be there, so re-typing will still be red.

If only want to select “read” “read” word, then how to operate? You just have to look under this tag

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);
}
Copy the code

You can see that the starting point is relative to the span element, not the outer div, which seems a little unnatural, right? Usually we want to specify a range for the outermost layer, such as [2,5], no matter what your structure is, directly select it, rather than manually find the specific tag like this, how to handle this?

The most important part of selection is to get the start and end points and offsets. How to get the innermost element information from the offsets relative to the outer elements?

Let’s say I have some HTML that looks a little bit complicated

<div>yux<span>Read the article<strong>The front end</strong>team</span></div>
Copy the code

Tried to find a lot of official documents, unfortunately there is no direct access to the API, can only layer by layer traversal. The overall idea is to obtain the information of the first layer through childNodes, which is divided into several sections. If the desired offset is in this section, continue traversing to the bottom layer, as shown below:

Just look at the red part (#text). In code

function getNodeAndOffset(wrap_dom, start=0, end=0){
    const txtList = [];
    const map = function(children){
        [...children].forEach(el= > {
            if (el.nodeName === '#text') {
                txtList.push(el)
            } else {
                map(el.childNodes)
            }
        })
    }
    // Recursively iterate to extract all #text
    map(wrap_dom.childNodes);
    // calculate the text position interval [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 satisfies the condition
    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]]}Copy the code

And with this method, I can select any interval, no matter what the structure is, okay

<div id="txt" contenteditable="true">Read the article,<span>include<span><strong>QQ</strong>reading</span>, Qidian Chinese, new beauty media and other well-known brands in the industry</span>, has a library of 14.5 million titles and 9.4 million members<span>The creator</span>Covering more than 200 content categories and reaching hundreds of millions of users, the company has successfully exported IP adaptation works in animation, film and television, games and other fields, including "Celebration of Life", "Uncle", "Ghost Blows out the Light", "Nirvana in Fire" and "The Master of The Full Time".</div>
Copy the code
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);
}
Copy the code

3. Focus in one place

This is relatively easy, just need to set the same starting point, for example, here want to move the cursor behind “QQ”, after “QQ” position is “8”, so you can achieve 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);
}
Copy the code

4. Restore the previous selection

There are two ways to do this. First, you can save the previous selection and then undo 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 the last selection
  selection.addRange(lastRange);
}
Copy the code

But this is not very reliable, the saved lastRange is very easy to lose, because it follows the content, if the content changes, the selection will not exist, so you need a more reliable way, such as recording the previous absolute offset, also need the previous traversal, find the lowest text node, Then calculate the offset relative to the whole text as follows:

function getRangeOffset(wrap_dom){
    const txtList = [];
    const map = function(children){
        [...children].forEach(el= > {
            if (el.nodeName === '#text') {
                txtList.push(el)
            } else {
                map(el.childNodes)
            }
        })
    }
    // Recursively iterate to extract all #text
    map(wrap_dom.childNodes);
    // calculate the text position interval [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 of the selection 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]
}
Copy the code

Then you can use this offset to actively select the region

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);
}
Copy the code

5. Insert (replace) the content in the specified selection

To insert content in a selection, you can use the range.insertNode method, which means to insert a node at the start of the selection, without replacing the currently selected node. If you want to replace the node, you can delete it first, and delete it needs to use the deleteContents method

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

The important thing to note here is that it must be a node. If it is text, you can create it with Document.createTextNode

You can also insert tagged content

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

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

The range. SetStartAfter method is used to set the start of the range after the element, and the end of the range is after the element by default

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

6. Label the package for the specified selection

Finally, a more common example is to wrap the selected area with a label when selected.

This is supported by an official API, which calls for a surround-layer of tags around a selection using the Range. surroundContents method

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

However, one drawback of this method is that when there is a “fault” in the selected area, as in this case, an error will be reported directly

There’s another way to get around this problem. It’s similar to replacing content, but you need to get the selection content. You can get the selection content using the range.ExtractContents method, which returns a DocumentFragment object. Add the selection to the new node, and then insert the new content as follows

btn.onclick = () = > {
    const mark = document.createElement('mark');
  // Record the selection
  mark.append(lastRange.extractContents())
  lastRange.insertNode(mark) 
}
Copy the code

Section & Range (codepen.io)

Five, summarize with two pictures

Remember, the cursor is a special selection, and it has nothing to do with whether the element is focused or not. Then there are the various apis. Here are two diagrams that show the general relationship

The above API is not comprehensive, but it covers most of the scenarios in normal development. If you want to know more comprehensive attributes and methods, you can check them on MDN.

With the popularity of vue, React and other frameworks, these native apis may be rarely mentioned. Most functional frameworks help us to encapsulate, but there are always some functions that cannot be satisfied, so we must rely on the “power of native”. Finally, if you think it’s good and helpful, please like, bookmark and retweet ❤❤❤