Advertisement

Spotlight: jQuery replaceText

by
Student iconAre you a student? Get a yearly Tuts+ subscription for $45 →

Every other week, we'll take an ultra focused look at an interesting and useful effect, plugin, hack, library or even a nifty technology. We'll then attempt to either deconstruct the code or create a fun little project with it.

Today, we're going to take a look at the excellent replaceText jQuery plugin. Interested? Let's get started after the jump.


A Word from the Author

As web developers, we have access to a staggering amount of pre-built code, be it a tiny snippet or a full fledged framework. Unless you're doing something incredibly specific, chances are, there's already something prebuilt for you to leverage. Unfortunately, a lot of these stellar offerings languish in anonymity, specially to the non-hardcore crowd.

This series seeks to rectify this issue by introducing some truly well written, useful code -- be it a plugin, effect or a technology to the reader. Further, if it's small enough, we'll attempt to deconstruct the code and understand how it does it voodoo. If it's much larger, we'll attempt to create a mini project with it to learn the ropes and hopefully, understand how make use of it in the real world.


Introducing replaceText

replaceText Blog post

We're kicking off things by focusing on Ben Alman's excellent replaceText plugin. Here is some quick info:

  • Type: Plugin
  • Technology: JavaScript [Built on the jQuery library]
  • Author: Ben Alman
  • Function: Unobtrusive, concise way to replace textual content

The Problem

Replacing content in your page sounds extremely simple. After all, the native JavaScript method replace seems to do the same thing. If you're feeling particularly lazy, jQuery makes replacing the entire content of the container obscenely easy too.

// Using just replace
$("#container").text().replace(/text/g,'replacement text')

// Replacing the *entire* content of the container
var lazyFool ="entire content with text replaced externally";
$("#container").html(lazyFool);

As the saying goes, just because you can do it doesn't really mean you should do. Both these methods are generally shunned [outside of edge cases] because they break a bunch of things whilst doing what they do.

The main issue with these approaches is that they flatten the DOM structure effectively screwing up every non-text node the container holds. If you manage to replace the html itself, using innerHTML or jQuery's html, you'll still unhook every event handler attached to any of its children, which is a complete deal breaker. This is the primary problem this plugin looks to solve.


The Solution

The best way to deal with the situation, and the way the plugin handles it, is to work with and modify text nodes exclusively.

Text nodes appear in the DOM just like regular nodes except that they can't contain childnodes. The text they hold can be obtained using either the nodeValue or data property.

By working with text nodes, we can make a lot of the complexities involved with the process. We'll essentially need to loop through the nodes, test whether it's a text node and if yes, proceed to manipulate it intelligently to avoid issues.

We'll be reviewing the source code of the plugin itself so you can understand how the plugin implements this concept in detail.


Usage

Like most well written jQuery plugins, this is extremely easy to use. It uses the following syntax:

$(container).replaceText(text, replacement);

For example, if you need to replace all occurrences of the word 'val' with 'value', for instance, you'll need to instantiate the plugin like so:

 $("#container").replaceText( "val", "value" );

Yep, it's really that simple. The plugin takes care of everything for you.

If you're the kind that goes amok with regular expressions, you can do that too!

 $("#container").replaceText( /(val)/gi, "value" );

You need not worry about replacing content in an element's attributes, the plugin is quite clever.


Deconstructing the Source

Since the plugin is made of only 25 lines of code, when stripped of comments and such, we'll do a quick run through of the source explaining which snippet does what and for which purpose.

Here's the source, for your reference. We'll go over each part in detail below.

  $.fn.replaceText = function( search, replace, text_only ) {
    return this.each(function(){
      var node = this.firstChild,
        val,
        new_val,
        remove = [];
      if ( node ) {
        do {
          if ( node.nodeType === 3 ) {
            val = node.nodeValue;
            new_val = val.replace( search, replace );
            if ( new_val !== val ) {
              if ( !text_only && /</.test( new_val ) ) {
                $(node).before( new_val );
                remove.push( node );
              } else {
                node.nodeValue = new_val;
              }
            }
          }
        } while ( node = node.nextSibling );
      }
      remove.length && $(remove).remove();
    });
  };

Right, let's do a moderately high level run through of the code.

 $.fn.replaceText = function( search, replace, text_only ) {};

Step 1 - The generic wrapper for a jQuery plugin. The author, rightly, has refrained from adding vapid options since the functionality provided is simple enough to warrant one. The parameters should be self explanatory -- text_only will be handled a bit later.

return this.each(function(){});

Step 2 - this.each makes sure the plugin behaves when the plugin is passed in a collection of elements.

var node = this.firstChild,
        val,
        new_val,
        remove = [];

Step 3 - Requisite declaration of the variables we're going to use.

  • node holds the node's first child element.
  • val holds the node's current value.
  • new_val holds the updated value of the node.
  • remove is an array that will contain node that will need to be removed from the DOM. I'll go into detail about this in a bit.
if ( node ) {}

Step 4 - We check whether the node actually exists i.e. the container that was passed in has child elements. Remember that node holds the passed element's first child element.

do{} while ( node = node.nextSibling );

Step 5 - The loop essentially, well, loops through the child nodes finishing when the loop is at the final node.

if ( node.nodeType === 3 ) {}

Step 6 - This is the interesting part. We access the nodeType property [read-only] of the node to deduce what kind of node it is. A value of 3 implies that is a text node, so we can proceed. If it makes life easier for you, you can rewrite it like so: if ( node.nodeType == Node.TEXT_NODE ) {}.

val = node.nodeValue;
new_val = val.replace( search, replace );

Step 7 - We store the current value of the text node, first up. Next, we quickly replace instances of the keyword with the replacement with the native replace JavaScript method. The results are being stored in the variable new_val.

if ( new_val !== val ) {}

Step 8 - Proceed only if the value has changed!

if ( !text_only && /</.test( new_val ) ) {
   $(node).before( new_val );
   remove.push( node );
}

Step 9a - Remember the text_only parameter. This comes into play here. This is used to specify whether the container should be treated as one which contains element nodes inside. The code also does a quick internal check to see whether it contains HTML content. It does so by looking for an opening tag in the contents of new_val.

If yes, the a textnode is inserted before the current node and the current node is added to the remove array to be handled later.

else {
         node.nodeValue = new_val;
        }

Step 9b - If it's just text, directly inject the new text into the node without going through the DOM juggling hoopla.

remove.length && $(remove).remove();

Step 10 - Finally, once the loop has finished running, we quickly remove the accumulated nodes from the DOM. The reason we're doing it after the loop has finished running is that removing a node mid-run will screw up the loop itself.


Project

The small project we're going to build today is quite basic. Here is the list of our requirements:

  • Primary requirement: Applying a highlight effect to text that's extracted from user input. This should be taken care of completely by the plugin.
  • Secondary requirement: Removing highlight on the fly, as required. We'll be drumming up a tiny snippet of code to help with this. Not production ready but should do quite well for our purposes.

Note: This is more of a proof of concept than something you can just deploy untouched. Obviously, in the interest of preventing the article from becoming unweildy, I’ve skipped a number of sections that are of utmost importance for production ready code -- validation for instance.

The actual focus here should be on the plugin itself and the development techniques it contains. Remember, this is more of a beta demo to showcase something cool that can be done with this plugin. Always sanitize and validate your inputs!


The Foundation: HTML and CSS

<!DOCTYPE html>  
<html lang="en-GB">  
	<head>
		<title>Deconstruction: jQuery replaceText</title>
		<link rel="stylesheet" href="style.css" />
	</head>

	<body>
    	<div id="container">
        	<h1>Deconstruction: jQuery replaceText</h1>
		<div>by Siddharth for the lovely folks at Nettuts+</div>
		
		<p>This page uses the popular replaceText plugin by Ben Alman. In this demo, we're using it to highlight arbitrary chunks of text on this page. Fill out the word, you're looking for and hit go. </p>
		
		<form id="search"><input id="keyword" type="text" /><a id="apply-highlight" href="#">Apply highlight</a><a id="remove-highlight" href="#">Remove highlight</a></form>
		<p id="haiz"> <-- Assorted text here --></div>
	<script src="js/jquery.js"></script>
	<script src="js/tapas.js"></script>

	</body>
</html>

The HTML should be pretty explanatory. All I've done is create a text input, two links to apply and remove the highlight as well as a paragraph containing some assorted text.

body{
	font-family: "Myriad Pro", "Lucida Grande", "Verdana", sans-serif;
	font-size: 16px;
}

p{
	margin: 20px 0 40px 0;
}


h1{
	font-size: 36px;
	padding: 0;
	margin: 7px 0;
}

h2{
	font-size: 24px;
}

#container{
	width: 900px;
	margin-left: auto;
	margin-right: auto;
	padding: 50px 0 0 0;
	position: relative;
}

#haiz { 
	padding: 20px; 
	background: #EFEFEF; 
	-moz-border-radius:15px;
	-webkit-border-radius: 15px;
	border: 1px solid #C9C9C9; 
}

#search {
	width: 600px; 
	margin: 40px auto; 
	text-align: center; 
}

#keyword { 
	width: 150px; 
	height: 30px; 
	padding: 0 10px; 
	border: 1px solid #C9C9C9; 
	-moz-border-radius:5px;
	-webkit-border-radius: 5px;
	background: #F0F0F0;
	font-size: 18px;
}

#apply-highlight, #remove-highlight { 
	padding-left: 40px; 
}

.highlight { 
	background-color: yellow;
}

Again, pretty self explanatory and quite basic. The only thing to note is the class called highlight that I'm defining. This will be applied to the text that we'll need to highlight.

At this stage, your page should look like so:

Tutorial image

The Interaction: JavaScript

First order of the day is to quickly hook up our link with their handlers so the text is highlighted and unhighlighted appropriately.

var searchInput = $("#keyword"), 
      searchTerm, 
      searchRegex;  
$("#apply-highlight").click(highLight);
$("#remove-highlight").bind("click", function(){$("#haiz").removeHighlight();});

Should be fairly simple. I declare a few variables for later use and attach the links to their handlers. highLight and removeHighlight are extremely simple functions we'll look at below.

function highLight() { 
   searchTerm = searchInput.val();
   searchRegex  = new RegExp(searchTerm, 'g');
   $("#haiz *").replaceText( searchRegex, '<span class="highlight">'+searchTerm+'</span>');
}
  • I've chosen to create a vanilla function, and not a jQuery plugin, because I'm lazy as a pile of rocks. We start off by capturing the input box's value.
  • Next up, we create a regular expression object using the search keyword.
  • Finally, we invoke the replaceText plugin by passing in the appropriate values. I'm choosing to directly include searchTerm in the markup for brevity.
jQuery.fn.removeHighlight = function() {
   return this.find("span.highlight").each(function() {
      with (this.parentNode) {
         replaceChild(this.firstChild, this);
      }
 })
};

A quick and dirty, hacky method to get the job done. And yes, this is a jQuery plugin since I wanted to redeem myself. The class is still hardcoded though.

I'm merely looking for every span tag with a class of highlight and replacing the entire node with the value it contains.

Before you get your pitchforks ready, remember that this is just for demonstration purposes. For your own application, you'll need a much more sophisticated unhighlight method.


Wrapping Up

And we're done. We took a look at an incredibly useful plugin, walked through the source code and finally finished by creating a mini project with it.