Using the nsIFind Interface

Oct 3, 2011

In a recent update to Googlebar Lite, I made a number of improvements to the search term highlighting feature, fixing several bugs along the way. This feature uses the nsIFind interface available in Firefox, which is poorly documented in my opinion. Unable to find any decent examples, I picked apart another extension I found that uses this interface, and I now better understand how it works. As such, I thought I'd provide an example of how to use this interface so that future developers won't have to dig down in the source like I did.

The following example will be simple, but we'll do some DOM modification along the way to spice things up. Note that this interface really only has one function that we care about: Find. Here's what it looks like:

nsIFind::Find(text, searchRange, startPoint, endPoint)

The parameters to this function are simple:

  • text: The text to search for
  • searchRange: A DOM Range specifying the domain of the search
  • startPoint: A DOM Range specifying the start point of the search
  • endPoint: A DOM Range specifying the end point of the search

Note that both the startPoint and endPoint parameters are ranges. Technically, these ranges can have different start and end points themselves, which complicates things depending on which direction you are searching (forwards or backwards). However, this example will avoid that scenario to keep things simple. I will also not discuss how to handle searching within frames on a website, something I'll leave as an exercise to the reader (hint: the solution involves recursion). Let's dive right in to the code:

// TODO: Note that in practice, we should do some error checking on
// the following three variables to make sure they are really available

var win = window.content; // Get a reference to the window's content
var doc = win.document; // Get a reference to the document
var body = doc.body; // Get a reference to the body element

var term = "Firefox"; // The term to find, hard-coded for this example

// Create a highlighted span element which we will clone
var span = doc.createElement("span");
span.setAttribute("style", "background: #FF0; color: #000; " +
                  "display: inline !important; font-size: inherit !important;");

// Create our search range
var searchRange = doc.createRange();
searchRange.selectNodeContents(body);

// Create the start point
var start = searchRange.cloneRange();
start.collapse(true); // Collapse to the beginning

// Create the end point
var end = searchRange.cloneRange();
end.collapse(false); // Collapse to the end

// Create the finder instance
var finder = Components.classes['@mozilla.org/embedcomp/rangefind;1']
        .createInstance(Components.interfaces.nsIFind);

// Perform the find operation
while((start = finder.Find(term, searchRange, start, end)))
{
    // Clone the highlighter node and surround our search results with it
    var hilitenode = span.cloneNode(true);
    start.surroundContents(hilitenode);

    // Collapse the starting range to its end point, so we don't find this
    // instance again the next time around the loop
    start.collapse(false);

    // Workaround for Firefox bug #488427
    body.offsetWidth;
}

There are a few sections of this code that are worth expanding on. As the "TODO" comment suggests, you should test to make sure that your references to the window's content actually exist. A simple if(!variable) test will suffice. Next, when creating our highlighting span, you'll note that I set a few interesting style rules: display:inline and font-size:inherit, both of which have the !important modifier applied to them. These rules help ensure that our inserted spans don't interfere too much with the existing page layout. I'm sure that additional rules could be added to make this even more battle hardened, but these are what I use in Googlebar Lite.

Next, when we create the search range, we use the selectNodeContents function to populate the range object. In our example, we select the contents of the "body" tag, which is essentially all of the page's content. Our start and end points are created by cloning our search range, then using the collapse function. Passing true to this function will collapse the range to its start point, while passing false will collapse to the end point.

Everything else should be fairly straightforward: we create an instance of the nsIFind interface, call its Find method, assigning our starting point to the result (so we don't repeatedly find the same instance of our search string). For each instance, we surround it with our "highlighted" span and we collapse the start point, again so we skip this instance the next time around the loop.

The last line of code in this function deserves some explanation. As you can see, we simply do a read on the offsetWidth property of the body element. This read essentially forces a reflow of the page, which flushes the changes we made to the DOM (inserting the our highlighted span). If we don't flush the changes by reflowing the page, the Find method will skip to the next sibling element in the DOM. Bug 488427 has all the details, though (as of this writing) its currently closed and marked as "worksforme." Nevertheless, this problem still persists as of Firefox 7.0.1, and this simple fix acts as a nice workaround. Note that this read must appear between the time you insert your element and the time you call nsIFind.

2 Comments

Little Spark

7:55 AM on Nov 17, 2011
Hi. The following works (tested on Firefox 5/8) without the use of offsetWidth. var retRange = null; while((retRange = finder.Find(term, searchRange, start, end))) { var hilitenode = span.cloneNode(true); retRange.surroundContents(hilitenode); start = retRange.cloneRange(); start.collapse(false); } omni/chrome/toolkit/content/global/bindings/findbar.xml

Jonah

4:29 PM on Nov 21, 2011
That may work, but it seems to me that you're introducing a lot of memory overhead that isn't needed. My workaround simply reads a value which triggers a reflow. Your method is cloning the search range on each hit. Those are objects that the garbage collector now has to deal with, not to mention the overhead in memory allocation.

Leave a Comment

Ignore this field:
Never displayed
Leave this blank:
Optional; will not be indexed
Ignore this field:
Both Markdown and a limited set of HTML tags are supported
Leave this empty: