If you like, please follow my blog or subscribe to RSS feed.

1. What is “word highlighting”?

For those of you who are not quite sure what “underline” means, here is a typical “underline” :

A sample site of the image above can be accessed here. The user selects a paragraph of text (that is, underline words), which will automatically add a highlighted background to the selected text, and users can easily add online notes to the web page.

Some time ago, I implemented a text highlighting online note-taking feature that is uncoupled from the content structure for online business. Decoupling means that there is no need to build a special page DOM structure for highlighting, which is almost transparent to the business. The core part of this function has strong versatility and portability, so take it out and share with you.

The specific core code in this paper has been packaged into an independent library web-highlighter. If you have any questions in reading, please refer to the code ↓↓.

2. What problems need to be solved to achieve “underline word”?

There are two core problems that need to be solved to realize a “underlined word” online note-taking function:

  • Highlight the background. That is, how to add highlighting background for the corresponding text according to the user’s selection on the web page;
  • Persistence and restore of highlighted areas. That is, how to save the user’s highlighted information and restore it accurately the next time you browse, otherwise the user’s highlighted information will be lost the next time you open the page.

In general, the business side of highlighting is mainly for the content you produce, you can easily control the layout of the content on the page, HTML tags and so on. In this case, it’s easier to handle the highlighting requirements because you can, after all, adjust the HTML of your existing content to fit your highlighting requirements.

In my case, the HTML layout of a page is complex and there is no way to drive business changes to the HTML based on highlighting requirements. This has led to the need for a more general solution, with the goal of “highlighting” any content and allowing subsequent access to restore the highlighting state, regardless of the organization of the content.

Let’s talk about how to solve the two core problems above.

3. How to “highlight the background”?

As we can see from the GIF demo, after the user selects a certain text (hereafter referred to as “user selection”), we will add a highlighted background to the text.

For example, the user selects the text shown above (in blue). The basic idea of highlighting it is as follows:

  1. Get the selected text node: Obtain all the selected text nodes through the region information selected by the user;
  2. Add background color to text nodes: Wrap these text nodes with a new element that has the specified background color.

3.1. How do I get the selected text node?

1) Selection API

It needs to be based on the Selection API that the browser provides for us. It has good compatibility. If you want to support lower versions of browsers, you need polyfill.

The Selection API returns a list of information about the user’s Selection. Is it possible to get all the DOM elements in the selection directly?

Unfortunately not. Fortunately, it returns the beginning and end of the selection:

const range = window.getSelection().getRangeAt(0);
const start = {
    node: range.startContainer,
    offset: range.startOffset
};
const end = {
    node: range.endContainer,
    offset: range.endOffset
};
Copy the code

The Range object contains the start and end of the selection, including nodes and text offsets. No need to say more about node information, here is to explain what offset refers to: For example, the tag

is an example of a piece of Text

, and the part selected by the user is “a piece of Text”. At this time, the nodes at the beginning and end are Text nodes in the P element, while startOffset and endOffset are 2 and 6 respectively.

2) Split the beginning and end text nodes

Once you understand the concept of offsets, you naturally find that there is a problem to be solved. Since a user selection may contain only a portion of a text node (offset is not zero), we expect the resulting user selection to contain only a portion of the beginning and end text nodes. To do this, we can splitText nodes using.splittext () :

/ / the first node
if (curNode === $startNode) {
    if (curNode.nodeType === 3) {
        curNode.splitText(startOffset);
        constnode = curNode.nextSibling; selectedNodes.push(node); }}/ / end nodes
if (curNode === $endNode) {
    if (curNode.nodeType === 3) {
        constnode = curNode; node.splitText(endOffset); selectedNodes.push(node); }}Copy the code

The above code splits the text node based on offset. For the start node, only the second half of it needs to be collected; For the end node it is the first half.

3) Traverse the DOM tree

So far, we’ve found the beginning and end nodes exactly, so the next step is to find all the text nodes “in between.” This requires traversing the DOM tree.

The “middle” is quoted because, visually, these nodes are located between the beginning and the end, but since the DOM is not a linear structure but a tree structure, the “middle” is translated into programming language and refers to depth-first traversal of all text nodes between the beginning and the end. There are many methods of DFS, including recursion and stack + loop, which I won’t go into here.

It should be mentioned that only the text nodes will be collected during the traverse since we are adding a highlighted background for the text nodes.

if (curNode.nodeType === 3) {
    selectedNodes.push(curNode);
}
Copy the code

3.2. How to add background color to text nodes?

This step in itself is not difficult. Building on the previous step, we have selected all the text nodes selected by the user (including the split beginning and end nodes). One of the most straightforward ways to do this is to “wrap” it with a background style element.

Specifically, we can surround each text node with a element with class highlight; The background style is set using the css.highlight selector.

// Get the text node in the selection using the method encapsulated in the previous step
const nodes = getSelectedNodes(start, end);

nodes.forEach(node= > {
    const wrap = document.createElement('span');
    wrap.setAttribute('class'.'highlight');
    wrap.appendChild(node.cloneNode(false));
    node.parentNode.replaceChild(wrap);
});
Copy the code
.highlight {
    background: #ff9;
}
Copy the code

This will add a “permanent” highlighting background to the selected text.

The overlap of P.S. constituencies

However, there is another tricky requirement in text highlighting — overlap of highlighted areas. For example, in the original demo (below), there was an overlap between the first highlighted area and the second highlighted area, the words “region height”.

This may not seem like a problem at the moment, but when combined with some of the features and requirements mentioned below, it can become cumbersome and even not work properly (some open source libraries don’t handle this as well, which is one reason they were not chosen). I’ll just mention it here, and I’ll talk about it in detail in the next section.

4. How to implement persistence and restore of highlighted selection?

So far we have been able to add a highlighted background to the selected text. But there’s one big problem:

Imagine how frustrating it would be for a user to exit a page after a lot of hard work (highlighting), only to find that none of it would save the next time they visited. Therefore, if you just do “one-time” text highlighting on a page, it becomes much less useful. This is why our “underline” function needs to be able to save (persist) these highlighted selections and restore them correctly.

At the heart of persistent highlighting selection is finding an appropriate way to serialize DOM nodes.

It can be seen from the third part that after determining the information of the beginning and end nodes and the text offset, the background color can be added to the text nodes between them. Where offset is a numeric type, there is no problem storing it on the server. DOM nodes, however, are different. Saving them in the browser requires only assigning to a variable, but saving the so-called DOM in the back end is less straightforward.

4.1 Serializing DOM node identifiers

So the core point here is to find a way to locate DOM nodes that can also be saved as plain JSON objects that can be passed to the back end for storage, a process referred to in this article as “serialization” of DOM identifiers. The next time the user accesses it, it can be fetched from the back end and “deserialized” to the corresponding DOM node.

There are several common ways to identify DOM nodes:

  • Using xPath
  • Use CSS Selector syntax
  • Use tagName + index

The third option has been chosen for quick implementation. Note that the first and last nodes retrieved through the Selection API are usually text nodes, and the tagName and index are the parent Element Node of the text Node. And childIndex indicates how many children the text node is the father’s:

function serialize(textNode, root = document) {
    const node = textNode.parentElement;
    let childIndex = - 1;
    for (let i = 0; i < node.childNodes.length; i++) {
        if (textNode === node.childNodes[i]) {
            childIndex = i;
            break; }}const tagName = node.tagName;
    const list = root.getElementsByTagName(tagName);
    for (let index = 0; index < list.length; index++) {
        if (node === list[index]) {
            return{tagName, index, childIndex}; }}return {tagName, index: - 1, childIndex};
}
Copy the code

The information returned by this method, plus the offset information, which locates the selected starting position, is also fully ready to be sent to the back end for saving.

4.2 Deserialize DOM nodes

Based on the serialization method in the previous section, once the data is retrieved from the back end, it can be easily deserialized to a DOM node:

function deSerialize(meta, root = document) {
    const {tagName, index, childIndex} = meta;
    const parent = root.getElementsByTagName(tagName)[index];
    return parent.childNodes[childIndex];
}
Copy the code

At this point, we have roughly solved the two core issues, and this seems to be a working version. But in fact, according to practical experience, if only the above processing, is often unable to deal with the actual demand, there are some “fatal problems”.

But don’t lose heart, here’s what that “killer problem” is, and how to solve it and implement a common “word highlighting” feature for online businesses.

5. How to implement a production-usable “word highlighting”?

1) What’s wrong with the above scheme?

Let’s start by looking at what’s wrong with the above scheme.

When we need to highlight text, we wrap the SPAN element around the text node, which changes the DOM structure of the page. This can result in the subsequent highlighting of the beginning and end nodes and their offsets being based on the modified DOM structure. The results are twofold:

  • On the next access, the program must be restored in the order highlighted by the last user.
  • The user cannot undelete (delete) the highlighted areas at will, but can only delete them from back to front in the order they are added.

Otherwise, some of the highlighted selections will not be able to locate the correct element during restore.

Text can be hard to understand, but let me give you an example to illustrate this visually.

<p>I am very happy to be here today to share with you the implementation of text highlighting (online notes).</p>
Copy the code

For the above HTML, the user highlighted two sections in order: “Happy” and “Text highlight.” So, with the above implementation, the HTML would look like this:

<p>very<span class="highlight">happy</span>I can share it with you here today<span class="highlight">Text highlighted</span>(online notes) implementation.</p>
Copy the code

The corresponding two serialized data are:

// Get serialized information when the word "happy" is highlighted
{
    start: {
        tagName: 'p'.index: 0.childIndex: 0.offset: 2
    },
    end: {
        tagName: 'p'.index: 0.childIndex: 0.offset: 4}}Copy the code
// The serialized information obtained when the words "text highlight" are highlighted.
// At this point, there is already a highlighted message (" happy ") under p.
// So its internal HTML structure has been modified. Intuitively, childNodes has changed.
// Furthermore, the childIndex attribute is changed to 2 due to the addition of the previous span element.
{
    start: {
        tagName: 'p'.index: 0.childIndex: 2.offset: 14
    },
    end: {
        tagName: 'p'.index: 0.childIndex: 2.offset: 18}}Copy the code

As you can see, the childIndex of both the “text highlight” and “text highlight” nodes is recorded as 2. This is because the previous highlighted area changes the DOM structure under the

element. If the “happy” selection is unhighlighted by the user, then the next time you visit the page, the “happy” selection is unhighlighted, and the third childNode will not appear under

, and the corresponding node will not be found if the childIndex is 2. This causes problems with the stored data when restoring highlighted selections.

Also, remember the highlight selection overlap problem mentioned at the end of Part 3? Support for selection overlap, it is easy to occur the following nested wrapping elements:

<p>very<span class="highlight">happy</span>I can share it with you here today<span class="highlight">The text<span class="highlight">The highlighted</span>
    </span>(online notes) implementation.</p>
Copy the code

This also results in complex nested structures that are different from the original HTML page when a text area is highlighted and unhighlighted many times. Predictably, the problems mentioned above also arise when we use xpath or CSS selectors as DOM identifiers, which also complicate the implementation of other requirements.

It’s worth mentioning how other open source libraries or products handle selection overlap:

  • The open source library Rangy has a Highlighter module for text highlighting, but it directly merges the two selections for overlap, which is not suitable for our business needs.
  • The paid product Diigo directly does not allow selection overlap.
  • Medium.com is constituency overlapping, and it’s a great experience, which is the goal of our product. But the structure of the content area of the page is much simpler and more manageable than what I was faced with.

So how do you solve these problems?

2) Alternative serialization/deserialization

I will improve the serialization method mentioned in Part 4. The tagName and index of the parent of the text node are still recorded, but instead of the index and offset of the text node in childNodes, the text offset of the start (end) position in the entire parent element node is recorded.

For example, the following HTML:

<p>very<span class="highlight">happy</span>I can share it with you here today<span class="highlight">Text highlighted</span>(online notes) implementation.</p>
Copy the code

For the “text highlight” highlight selection, the previous information used to identify the start of the text was childIndex = 2, offset = 14. This is now offset = 18 (starting from the first text “not” of the

element and then “text” after 18 characters). As can be seen, the advantage of this representation is that no matter how the original text node inside the

is divided by the < SPAN > (wrapped) node, it does not affect the node positioning during highlighted selection restoration.

Accordingly, at serialization time, we need a method to “translate” the offset within the text node to the total text offset within its corresponding parent node:

function getTextPreOffset(root, text) {
    const nodeStack = [root];
    let curNode = null;
    let offset = 0;
    while (curNode = nodeStack.pop()) {
        const children = curNode.childNodes;
        for (let i = children.length - 1; i >= 0; i--) {
            nodeStack.push(children[i]);
        }

        if (curNode.nodeType === 3&& curNode ! == text) { offset += curNode.textContent.length; }else if (curNode.nodeType === 3) {
            break; }}return offset;
}
Copy the code

Restoring a highlight selection requires a corresponding reverse process:

function getTextChildByOffset(parent, offset) {
    const nodeStack = [parent];
    let curNode = null;
    let curOffset = 0;
    let startOffset = 0;
    while (curNode = nodeStack.pop()) {
        const children = curNode.childNodes;
        for (let i = children.length - 1; i >= 0; i--) {
            nodeStack.push(children[i]);
        }
        if (curNode.nodeType === 3) {
            startOffset = offset - curOffset;
            curOffset += curNode.textContent.length;
            if (curOffset >= offset) {
                break; }}}if(! curNode) { curNode = parent; }return {node: curNode, offset: startOffset};
}
Copy the code

3) Support overlap of highlighted selections

One of the problems with overlapping highlighted selections is the nesting of highlighted wrapped elements, which can cause complex changes to the DOM structure, increasing the complexity of other functionality (interaction) implementations and troubleshooting. Therefore, when wrapping highlighted elements as I mentioned in section 3.2, I will do some more complicated processing (especially the overlap selection) to ensure that existing wrapping elements are reused as much as possible and elements are not nested.

During processing, each Text Node that needs to be wrapped is divided into three cases:

  1. If it is not wrapped at all, it is wrapped directly.
  2. Is part of a wrapped text node.splitText()Break it up.
  3. Is a completely wrapped text segment that does not require node processing.

At the same time, a unique ID is generated for each selection, and the corresponding ID of the text, as well as any other IDS involved due to the overlap of the selection, are appended to the wrapped element. Therefore, as in the third case above, the DOM structure does not need to be changed, only the dataset attributes corresponding to the two ids of the wrapped elements can be updated.

6. Other questions

With some of the above problems solved, text highlighting is basically available. There are a few “minor fixes” left, just to mention.

6.1. Highlight selected interaction events, such as Click and hover

First, you can generate a unique ID for each highlighted selection and then record that ID information on all wrapped elements within that selection, for example with the data-highlight-ID attribute. For overlapped parts, the ids of other overlapped selections can be recorded in the data-highlight-extra-id attribute.

After listening for the click and hover of the wrapped element, it triggers the corresponding event of highlighter and carries the highlight ID.

6.2. Unhighlighting (removal of highlighted background)

Since selection overlap is supported when wrapping (corresponding to the three cases mentioned above), there are also three cases that need to be handled respectively when deleting selection highlights:

  • Delete the wrap element directly. There is no overlap.
  • updatedata-highlight-idProperties anddata-highlight-extra-idProperties. That is, remove the highlighted ID anddata-highlight-idThe same.
  • Update onlydata-highlight-extra-idProperties. That is, remove the highlighted ID only indata-highlight-extra-idIn the.

6.3. What about the dynamic pages generated by the front-end?

It is easy to see that this uncoupled text highlighting function is very dependent on the DOM structure of the page, so you need to make sure that the DOM structure of the highlight is the same as that of the restore, otherwise the starting node position of the selection cannot be correctly restored. Accordingly, the most friendly to highlighting would be purely back-end rendered pages that trigger the highlighting selection restore method in the onLoad listener. But now more and more pages (or parts of a page) are dynamically generated in the front end, how to deal with this problem?

I run into a similar problem in practice — many areas of the page are rendered front-end after Ajax requests. My handling methods include the following:

  • Isolate the range of variation. Take the “root node” in the code above fromdocumentElementSwitch to another, more specific container element. For example, I will face a business with id asarticle-container<div>Load dynamic content inside, so I’m going to specify thatarticle-containerIs the root node. This minimizes the need for external DOM changes to affect the location of the highlights, especially page changes.
  • Determines the restore timing of the highlighted selection. Because the content may be dynamically generated, you need to wait until the DOM rendering of that part is complete before invoking the restore method. If there are exposed listening events, they can be handled within the listening. Or use MutationObserver to listen for the signature element to see if the part is loaded.
  • To record business content information, the content area should be modified. Changes to the DOM structure of the content area can be devastating. If there is such a case, we can try to let the business content presenter bind the paragraph information and other specific content information to the DOM element, and I take out the information when highlighting for redundant storage. After revision, we can “brush” the stored data through the content information.

6.4. Other

Length issues and other details will not be shared in this article. See the implementation in the web-highlighter repository for details.

7. To summarize

This paper starts from the two core issues of “highlighting” (how to highlight the text selected by the user and how to restore the highlighted Selection), and realizes the core function of “highlighting” based on the Selection API, depth-first traversal and DOM node identifier serialization. However, this scheme still has some practical problems, so the corresponding solutions are further presented in Part 5.

Based on the actual development experience, I found that the code to solve the above core problems of “highlighting” had certain universality, so I packaged the source code of the core part into an independent library web-highlighter, which was hosted on Github and could also be installed through NPM.

It has been used for online product business, and the basic highlighting function can be opened with a line of code:

(new Highlighter()).run();
Copy the code

Compatible with IE 10/11, Edge, Firefox 52+, Chrome 15+, Safari 5.1+, Opera 15+.

Interested partners can star. Thank you for your support, welcome to exchange 😊