JavaScript DOM Ranges

JavaScript DOM Ranges

By Nicholas C. Zakas

The DOM is a very exciting set of functionality for web pages, but most of the attention has been drawn by the standard DOM functionality. Many developers are unaware that the DOM has much more to offer than just createElement() and appendChild(); DOM ranges are a very powerful tool for dynamic web page manipulation.

A range can be used to select a section of a document regardless of node boundaries (note that the selection occurs behind the scenes and cannot be seen by the user). Ranges are helpful when regular DOM manipulation isn't specific enough to change a document.

DOM Level 2 defines a method called createRange() to, well, create ranges. In DOM-compliant browsers (not Internet Explorer, by the way), this method belongs to the document object, so a new range can be created like this:

var oRange = document.createRange();

Just like nodes, a range is tied directly to a document. To determine if the document supports DOM-style ranges, you can use the hasFeature() method:

var supportsDOMRanges = document.implementation.hasFeature("Range", "2.0");

If you plan to use DOM ranges, it is always best to make this check first and wrap your code in an if statement:

if (supportsDOMRange) {
   var oRange = document.createRange();
   //range code here
}

Simple selection in DOM ranges

The simplest way to select a part of the document using a range is to use either selectNode() or selectNodeContents(). These methods each accept one argument, a DOM node, and fill a range with information from that node.

The selectNode() method selects the entire node, including its children, whereas selectNodeContents() selects all of the node's children. For example, consider the following:

<p id="p1"><b>Hello</b> World</p>

This code can be accessed using the following JavaScript:

var oRange1 = document.createRange();
var oRange2 = document.createRange();
var oP1 = document.getElementById("p1");
oRange1.selectNode(oP1);
oRange2.selectNodeContents(oP1);

The two ranges in this example contain different sections of the document: oRange1 contains the <p> element and all its children, whereas oRange2 contains the <b/> element and the text node World (see Figure 1).

Figure 1

Whenever you create a range, a number of properties are assigned to it:

  • startContainer — The node within which the range starts (the parent of the first node in the selection)
  • startOffset — The offset within the startContainer where the range starts. If startContainer is a text node, comment node, or CData node, the startOffset is the number of characters skipped before the range starts; otherwise, the offset is the index of the first child node in the range.
  • endContainer — The node within which the range ends (the parent of the last node in the selection)
  • endOffset — The offset within the endContainer where the range ends (follows the same rules as startOffset)
  • commonAncestorContainer — The first node within which both startContainer and endContainer exist

These properties are all read-only and are designed to give you additional information about the range.

When you use selectNode(), the startContainer, endContainer, and commonAncestorContainer are all equal to the parent node of the node that was passed in; startOffset is equal to the index of the given node within the parent's childNodes collection, whereas endOffset is equal to the startOffset plus one (because only one node is selected).

When you use selectNodeContents(), startContainer, endContainer, and commonAncestorContainer are equal to the node that was passed in; startOffset is equal to 0; endOffset is equal to the number of child nodes (node.childNodes.length).

The following example illustrates these properties:

<html>
  <head>
    <title>DOM Range Example</title>
    <script type="text/javascript">
      function useRanges() {
          var oRange1 = document.createRange();
          var oRange2 = document.createRange();
          var oP1 = document.getElementById("p1");
          oRange1.selectNode(oP1);
          oRange2.selectNodeContents(oP1);
          
          document.getElementById("txtStartContainer1").value 
              = oRange1.startContainer.tagName;
          document.getElementById("txtStartOffset1").value = 
              oRange1.startOffset;
          document.getElementById("txtEndContainer1").value = 
              oRange1.endContainer.tagName;
          document.getElementById("txtEndOffset1").value = 
              oRange1.endOffset;
          document.getElementById("txtCommonAncestor1").value = 
              oRange1.commonAncestorContainer.tagName;
          document.getElementById("txtStartContainer2").value = 
              oRange2.startContainer.tagName;
          document.getElementById("txtStartOffset2").value = 
              oRange2.startOffset;
          document.getElementById("txtEndContainer2").value = 
              oRange2.endContainer.tagName;
          document.getElementById("txtEndOffset2").value = 
              oRange2.endOffset;
          document.getElementById("txtCommonAncestor2").value = 
              oRange2.commonAncestorContainer.tagName;
        }
    </script>
  </head>
  <body><p id="p1"><b>Hello</b> World</p>
    <input type="button" value="Use Ranges" onclick="useRanges()" />
    <table border="0">
    <tr>
        <td>
          <fieldset>
              <legend>oRange1</legend>
              Start Container: 
                  <input type="text" id="txtStartContainer1" /><br />
              Start Offset: 
                  <input type="text" id="txtStartOffset1" /><br />
              End Container: 
                  <input type="text" id="txtEndContainer1" /><br />
              End Offset: 
                  <input type="text" id="txtEndOffset1" /><br />
              Common Ancestor: 
                  <input type="text" id="txtCommonAncestor1" /><br />
          </fieldset>
        </td>
        <td>
          <fieldset>
              <legend>oRange2</legend>
              Start Container: 
                  <input type="text" id="txtStartContainer2" /><br />
              Start Offset: 
                  <input type="text" id="txtStartOffset2" /><br />
              End Container: 
                  <input type="text" id="txtEndContainer2" /><br />
              End Offset: 
                  <input type="text" id="txtEndOffset2" /><br />
              Common Ancestor: 
                  <input type="text" id="txtCommonAncestor2" /><br />
          </fieldset>
        </td>
    </tr>
    </table>
  </body>
</html>

Figure 2 displays the result when this example is run in a DOM-compliant browser, such as Firefox.

Figure 2

As you can see, oRange1's startContainer, endContainer, and commonAncestorContainer are equal to the <body/> element because the <p/> element is wholly contained within it. Also, startOffset is equal to 0, because the <p/> element is the first child of <p/>, and endOffset is equal to 1, meaning that the range is over before the second child node (which is index 1).

Looking over at oRange2's information gathered by selectNodeContents(), startContainer, endContainer, and commonAncestorContainer are equal to the <p/> element itself because you are selecting its children. The startOffset is equal to 0, because the selection begins with the first child node of <p/>. The endOffset is equal to 2 because there are two child nodes of <p/>: <b/> and the text node World.

Several methods help you get more specific with selections while still setting these properties for you. These are the following:

  • setStartBefore(refNode) — Sets the starting point of the range to begin before refNode (so refNode is the first node in the selection). The startContainer property is set to refNode's parent and the startOffset property is set to the index of refNode within its parent's childNodes collection.
  • setStartAfter(refNode) — Sets the starting point of the range to begin after refNode (so refNode is not part of the selection; rather, its next sibling is the first node in the selection). The startContainer property is set to refNode's parent and the startOffset property is set to the index of refNode within its parent's childNodes collection plus one.
  • setEndBefore(refNode) — Sets the ending point of the range to begin before refNode (so refNode is not part of the selection; its previous sibling is the last node in the selection). The endContainer property is set to refNode's parent and the endOffset property is set to the index of refNode within its parent's childNodes collection.
  • setEndAfter(refNode) — Sets the ending point of the range to begin before refNode (so refNode is the last node in the selection). The endContainer property is set to refNode's parent and the endOffset property is set to the index of refNode within its parent's childNodes collection plus one.

Using any of these methods, all properties are assigned for you. However, it is possible to assign these values directly in order to make complex range selections.

JavaScript DOM Ranges

Complex selection in DOM ranges

Creating complex ranges requires the use of range setStart() and setEnd() methods. Both methods accept two arguments: a reference node and an offset. For setStart(), the reference node becomes the startContainer, and the offset becomes the startOffset; for setEnd(), the reference node becomes the endContainer, and the offset becomes the endOffset.

Using these methods, it is possible to mimic selectNode() and selectNodeContents(). For example, the useRanges() function in the previous example can be rewritten using setStart() and setEnd():

function useRanges() {
    var oRange1 = document.createRange();
    var oRange2 = document.createRange();
    var oP1 = document.getElementById("p1");
    var iP1Index = -1;
    for (var i=0; i < oP1.parentNode.childNodes.length; i++) {
        if (oP1.parentNode.childNodes[i] == oP1) {
            iP1Index = i;
            break;
        }
    }

    oRange1.setStart(oP1.parentNode, iP1Index);
    oRange1.setEnd(oP1.parentNode, iP1Index + 1);
    oRange2.setStart(oP1, 0);
    oRange2.setEnd(oP1, oP1.childNodes.length);
    //textbox assignments here
}

Note that to select the node (using oRange1), you must first determine the index of the given node (oP1) in its parent node's childNodes collection. To select the node contents (using oRange2), no calculations are necessary. But you already know easier ways to select the node and node contents; the real power here is to be able to select only parts of nodes.

Recall the very first example mentioned in this section, selecting llo from Hello and Wo from World in the HTML code <p id="p1"><b>Hello</b> World</p>. Using setStart() and setEnd(), this is quite easy to accomplish.

The first step in the process is to get references to the text nodes containing Hello and World using the regular DOM methods:

var oP1 = document.getElementById("p1");
var oHello = oP1.firstChild.firstChild;
var oWorld = oP1.lastChild;

The Hello text node is actually a grandchild of <p/> because it's apparently <b/>, so you can use oP1.firstChild to get <b/> and oP1.firstChild.firstChild to get the text node. The World text node is the second (and the last) child of <p/>, so you can use oP1.lastChild to retrieve it.

Next, create the range and set the appropriate offsets:

var oP1 = document.getElementById("p1");
var oHello = oP1.firstChild.firstChild;
var oWorld = oP1.lastChild;
var oRange = document.createRange();
oRange.setStart(oHello, 2);
oRange.setEnd(oWorld, 3);

For setStart(), the offset is 2, because the first l in Hello is in position 2 (starting from H in position 0). For setEnd(), the offset is 3, indicating the first character that should not be selected, which is r in position 3. (There is actually a space in position 0. See Figure 3.)

Figure 3

Because both oHello and oWorld are text nodes, they become the startContainer and endContainer for the range so that the startOffset and endOffset accurately look at the text contained within each node instead of looking for child nodes, which is what happens when an element is passed in. The commonAncestorContainer is the <p/> element, which is the first ancestor that contains both nodes.

Of course, just selecting sections of the document isn't very useful unless you can interact with the selection.

There is a bug in Mozilla's implementation of the DOM Range (bug #135928) that causes an error to occur when you try to use setStart() and setEnd() with the same text node. This bug has been resolved and this fix is included in a future Mozilla release.

Interacting with DOM range content

When a range is created, internally it creates a document fragment node onto which all the nodes in the selection are attached. Before this can happen, however, the range must make sure that the selection is well-formed.

You just learned that it is possible to select the entire area from the first letter l in Hello to the o in World, including the </b> end tag (see Figure 4). This would be impossible using the normal DOM methods described in the book Professional JavaScript for Web Developers (Wrox, 2005, ISBN: 0-7645-7908-8).

Figure 4

The reason a range can get away with this trick is that it recognizes missing opening and closing tags. In the previous example, the range calculates that a <b> start tag is missing inside the selection, so the range dynamically adds it behind the scenes, along with a new </b> end tag to enclose He, thus altering the DOM to the following:

<p><b>He</b><b>llo</b> World</p>

The document fragment contained within the range is displayed in Figure 5.

Figure 5

With the document fragment created, you can manipulate the contents of the range using a variety of methods.

The first method is the simplest to understand and use: deleteContents(). This method simply deletes the contents of the range from the document. In the previous example, calling deleteContents() on the range leaves this HTML in the page:

<p><b>He</b>rld</p>

Because the entire document fragment is removed, the range is kind enough to place the missing </b> tag into the document so it remains well-formed.

extractContents() is similar to deleteContents(). It also removes the range selection from the document and returns the range's document fragment as the function value. This allows you to insert the contents of the range somewhere else:

var oP1 = document.getElementById("p1");
var oHello = oP1.firstChild.firstChild;
var oWorld = oP1.lastChild;
var oRange = document.createRange();
oRange.setStart(oHello, 2);
oRange.setEnd(oWorld, 3);
var oFragment = oRange.extractContents();
document.body.appendChild(oFragment);

In this example, the fragment is extracted and added to the end of the document's <body/> element (remember, when a document fragment is passed into appendChild(), only the fragment's children are added, not the fragment itself). What you see in this example is the code <b>He</b>rld at the top of the page, and <b>llo</b> Wo at the bottom of the page.

Another option is to leave the fragment in place, but create a clone of it that can be inserted elsewhere in the document by using cloneContents():

var oP1 = document.getElementById("p1");
var oHello = oP1.firstChild.firstChild;
var oWorld = oP1.lastChild;
var oRange = document.createRange();
oRange.setStart(oHello, 2);
oRange.setEnd(oWorld, 3);
var oFragment = oRange.cloneContents();
document.body.appendChild(oFragment);

This method is very similar to deleteContents() because both return the range's document fragment. This results in <b>llo</> Wo being added to the end of the page; the original HTML code remains intact.

The document fragment and accompanying changes to the range selection do not happen until one of these methods is called. The original HTML remains intact right up until that point.

JavaScript DOM Ranges

Inserting DOM range content

The previous three methods all dealt with removing information from the range in one way or another. It is also possible to add content to the range using a couple of different methods.

The insertNode() method enables you to insert a node at the beginning of the selection. Suppose you wanted to insert the following HTML code into the range defined in the previous section:

<span style="color: red">Inserted text</span>

The following code accomplishes this:

var oP1 = document.getElementById("p1");
var oHello = oP1.firstChild.firstChild;
var oWorld = oP1.lastChild;
var oRange = document.createRange();
var oSpan = document.createElement("span");
oSpan.style.color = "red";
oSpan.appendChild(document.createTextNode("Inserted text"));

oRange.setStart(oHello, 2);
oRange.setEnd(oWorld, 3);
oRange.insertNode(oSpan);

Running this JavaScript effectively creates the following HTML code:

<p id="p1"><b>He<span style="color: red">Inserted text</span>llo</b>
              World</p>

Note that the <span/> is inserted just before the llo in Hello, which is the first part of the range selection. Also note that the original HTML didn't add or remove <b/> elements because none of the methods introduced in the previous section were used. You can use this technique to insert helpful information, such as an image next to links that open in a new window.

Along with inserting into the range, it is possible to insert content surrounding the range by using the surroundContents() method. This method accepts one parameter, which is the node that surrounds the range contents. Behind the scenes, the following steps are taken:

  1. The contents of the range are extracted (similar to extractContents()).
  2. The given node is inserted into the position in the original document where the range was.
  3. The contents of the document fragment is added to the given node.

This sort of functionality is useful online to highlight certain words in a web page, like this:

var oP1 = document.getElementById("p1");
var oHello = oP1.firstChild.firstChild;
var oWorld = oP1.lastChild;
var oRange = document.createRange();
var oSpan = document.createElement("span");
oSpan.style.backgroundColor = "yellow";

oRange.setStart(oHello, 2);
oRange.setEnd(oWorld, 3);
oRange.surroundContents(oSpan);

The previous code highlights the range selection with a yellow background.

Collapsing a DOM Range

To empty a range, (that is, to have it select no part of the document), you collapse it. Collapsing a range resembles the behavior of a text box. When you have text in a text box, you can highlight an entire word using the mouse. However, if you left-click the mouse again, the selection is removed and the cursor is located between two letters. When you collapse a range, you are setting its locations between parts of a document, either at the beginning of the range selection or at the end. Figure 6 illustrates what happens when a range is collapsed.

Figure 6

You can collapse a range by using the collapse() method, which accepts a single argument: a Boolean value indicating which end of the range to collapse to. If the argument is true, then the range is collapsed to its starting point; if false, the range is collapsed to its ending point. To determine if a range is collapsed, you can use the collapsed property:

oRange.collapse(true);      //collapse to the starting point
alert(oRange.collapsed);    //outputs "true"

Testing whether a range is collapsed is helpful if you aren't sure if two nodes in the range are next to each other. For example, consider this HTML code:

<p id="p1">Paragraph 1</p><p id="p2">Paragraph 2</p>

If you don't know the exact makeup of this code (because, perhaps, it is automatically generated), you might try creating a range like this:

var oP1 = document.getElementById("p1");
var oP2 = document.getElementById("p2");
var oRange = document.createRange();
oRange.setStartAfter(oP1);
oRange.setStartBefore(oP2);
alert(oRange.collapsed);    //outputs "true"

In this case, the created range is collapsed because there is nothing between the end of p1 and the beginning of p2.

Comparing DOM ranges

If you have more than one range, you can use the compareBoundaryPoints() method to determine if the ranges have any boundaries (start or end) in common. The method accepts two arguments: the range to compare to and how to compare, which is a constant value:

  • * START_TO_START (0) — Compares the starting point of the first range to the starting point of the second
  • START_TO_END (1) — Compares the starting point of the first range to the end point of the second
  • END_TO_END (2) — Compares the end point of the first range to the end point of the second.
  • END_TO_START (3) — Compares the end point of the first range to the start point of the second

The compareBoundaryPoints() method returns -1 if the point from the first range comes before the point from the second range, 0 if the points are equal, or 1 if the point from the first range comes after the point from the second range.

For example:

var oRange1 = document.createRange();
var oRange2 = document.createRange();
var oP1 = document.getElementById("p1");
oRange1.selectNodeContents(oP1);
oRange2.selectNodeContents(oP1);
oRange2.setEndBefore(oP1.lastChild);
alert(oRange1.compareBoundaryPoints(Range.START_TO_START, oRange2));
    //outputs 0
alert(oRange1.compareBoundaryPoints(Range.END_TO_END, oRange2));
    //outputs 1;

In this code, the starting points of the two ranges are exactly the same because both use the default value from selectNodeContents(); therefore, the method returns 0. For oRange2, however, the end point is changed using setEndBefore(), making the end point of oRange1 come after the end point of oRange2 (see Figure 7), so the method returns 1.

Figure 7

Cloning DOM ranges

If you find the need, you can duplicate any range by calling the cloneRange() method. This method creates an exact duplicate of the range on which it is called:

var oNewRange = oRange.cloneRange();

The new range contains all of the same properties as the original and can be modified without affecting the original in any way.

Clean up

When you are done using a range, it is best to call the detach() method to free up system resources. This isn't required because dereferenced ranges are picked up by the garbage collector eventually. If, however, the range is used initially and then no longer required, calling detach() ensures that it isn't taking up any more memory than necessary:

oRange.detach();

This article is adapted from Professional JavaScript for Web Developers by Nicholas C. Zakas (Wrox, 2006, ISBN: 0-471-77778-1), from Chapter 10, "Advanced DOM Techniques."

Copyright 2005 by WROX. All rights reserved. Reproduced here by permission of the publisher.



About the Author

Nicholas Zakas

Nicholas C. Zakas is the author of Professional Ajax by (WROX, ISBN: 0-471-77778-1) and Professional JavaScript for Web Developers (WROX, ISBN: 0-7645-7908-8).

Comments

  • There are no comments yet. Be the first to comment!

Leave a Comment
  • Your email address will not be published. All fields are required.

Top White Papers and Webcasts

  • Live Event Date: September 10, 2014 @ 11:00 a.m. ET / 8:00 a.m. PT Modern mobile applications connect systems-of-engagement (mobile apps) with systems-of-record (traditional IT) to deliver new and innovative business value. But the lifecycle for development of mobile apps is also new and different. Emerging trends in mobile development call for faster delivery of incremental features, coupled with feedback from the users of the app "in the wild". This loop of continuous delivery and continuous feedback is …

  • This ESG study by Mark Peters evaluated a common industry-standard disk VTl deduplication system (with 15:1 reduction ratio) versus a tape library with LTO-5, drives with full nightly backups, over a five-year period.  The scenarios included replicated systems and offsite tape vaults.  In all circumstances, the TCO for VTL with deduplication ranged from about 2 to 4 times more expensive than the LTO-5 tape library TCO. The paper shares recent ESG research and lots more. 

Most Popular Programming Stories

More for Developers

Latest Developer Headlines

RSS Feeds