If you often find yourself browsing large, text-heavy web pages and needing to link to specific portions of the text (whether for personal reference, to be able to point a friend or colleague to a quote or a piece of information, or for any other reason), you've probably seen the value first-hand in accommodating users with named anchors (e.g. <a name="anamedanchor">a named anchor "anamedanchor"</a>, linkable using <a href="#anamedanchor">to the named anchor named "anamedanchor"</a>) sprinkled liberally throughout your HTML source.
Aside from an altruistic desire to make the lives of people who may or may not visit your site or need to refer to your pages frequently a little bit easier, why go to all of the trouble of infusing your html with lots of named anchors? One of the aspects of the Web that makes it so amazing is the low barrier to authorship and the high likelihood that a given web visitor to your site is both a content consumer and provider rolled into one as compared to the incredibly low probability that someone reading a news paper is, for instance, a newspaper publisher. Providing a high degree of "linking granularity" within pages on your site is in your own best interest, because it allows visitor-authors to comment on or react to specific portions of your pages and share links to exactly what sparked their interest/ire/curiosity/agreement with others. That translates into a higher quality of discourse with your readers. Following this line of thought to it's natural conclusion, it occurred to me that the ideal practice would be not merely to place anchors at regular intervals throughout the HTML source, but, above and beyond a default set of hard coded anchors, to provide a mechanism whereby visitors could make their own bookmarkable and shareable named anchors wherever they liked within my pages.
In this article, my aim is to impart a conceptual understanding of how JavaScript and DOM (specifically, Mozilla's window.getSelection() method and the W3C DOM Range API) can be used to enable user-driven text selection and linking throughout your pages for visitors piloting Mozilla, Netscape 6.x, Netscape 7.x, and other Mozilla-based browsers. I've dubbed the system that I created and implemented along these lines at my own site "Ahoy", in-keeping with the nautical theme underlying so much of the web lexicon (navigator for web browser, anchor elements for linking, etc.). For reasons that I'll explain at the end of this article, although the Mozilla-specific code is confined to less than half a dozen lines in a several-hundred-line-long script, I haven't ported Ahoy to Microsoft's Internet Explorer browser.
This article assumes that you have a general understanding of HTML, JavaScript, W3C DOM Level 1 Core, and W3C DOM Level 2 Events. If you need a refresher in the aspects of W3C DOM Level 1 Core and W3C DOM Level 2 Events that I'll be using here, I highly recommend that you read two excellent articles by Scott Andrew LePera : "Dynamic Content with DOM-2" and "Working With Events in Netscape 6".
Below are two step-by-step explanations of how Ahoy works, first from a user's perspective and then with the mechanics of the script superimposed over the user's-eye-view.
Since our goal is to allow users to obtain a bookmarkable url for a discrete fragment of text within a page by clicking and dragging with their mouse, we'll need to make sure that we've got a way to get our mitts on the selected text before we begin coding, since no means of grabbing users' text selections is defined in any of the W3C DOM recommendations. Luckily for us, Mozilla's Level 0 DOM implementation includes the window.getSelection() method, which returns a selection object that we can use for our project. The entry for this method in the Gecko DOM Reference at Mozilla.org refers readers to the nsISelection interface for information on the properties and methods of these objects, but Jeff Yates has included a more detailed and lucid discussion of Mozilla's selection objects in his article "Using the W3C DOM Level 2 Range Object as Implemented by Mozilla". For Ahoy, we'll be using the toString() method and the anchorOffset and focusOffset properties for the selection objects that we obtain via window.getSelection().
Ideally, Ahoy should be as easy to implement as possible (meaning that it should involve little or no modification of the pages' source other than the inclusion of a script element referencing ahoy.js) and use no browser-specific JavaScript and DOM extensions aside from getSelection(), for which there's no W3C-defined alternative. We need to capture mousedown and mouseup events within the browser window in order to store references to the container element(s) of the selection and we don't want to do anything until the entire page has loaded - so we will be concerned exclusively with the load, mousedown, and mouseup events.
Mozilla has implemented the W3C DOM Level 2 Events module, so we can register event listeners for the window object using addEventListener(). Because many other browsers don't yet support the W3C DOM Level 2 Events module, we'll need to detect whether it's supported before using it :
if(window.addEventListener){
window.addEventListener("load", ahy_DoOnLoad, false);
}else{
alert("Sorry, but your browser does not support the addEventListener() method.");
}
In the code fragment above, we've registered a function named "ahy_DoOnload" to be called when the window.onload event travels up (up, instead of down, because we've specified the Boolean false instead of true as the value for the capture parameter) to the window object. In this sample of code, if the browser doesn't grok window.addEventListener, an alert dialog box with the message "Sorry, but your browser does not support the addEventListener() method".
We will test for the availability of window.getSelection(). Afterwards, because we'll be using multiple methods within the W3C DOM Level 2 Range API (createRange(), setStart(), setEnd(), and toString()), we'll employ document.implementation.hasFeature() to confirm the availability of this module of the W3C Level 2 DOM as a whole instead of testing for the availability of all of the methods separately. Since we'll have already verified that the Mozilla-specific getSelection() method is available once we check for the Range API (which we know has been implemented in Mozilla) with document.implementation.hasFeature(), this second check may seem unnecessary, but it costs us nothing (remember the saying "trust, but verify"). Note : document.implementation is not available in Netscape before the Mozilla-based releases (i.e. pre-NS6) or in Internet Explorer before MSIE6, so it's best to test for getSelection first in order to avoid triggering error messages in older browsers when they encounter the call to document.implementation.hasFeature(). Below, we define the ahy_DoOnload function :
ahy_DoOnLoad
function ahy_DoOnLoad()
{
if(window.getSelection){
var blnRangeImplemented = document.implementation.hasFeature("Range", "2.0");
if(blnRangeImplemented){
objSelectedTextRange = new ahyObjSelectedTextRange();
document.addEventListener("mousedown", objSelectedTextRange.ahy_StartSelect, false);
document.addEventListener("mouseup", objSelectedTextRange.ahy_EndSelect, false);
}else{
alert("Sorry, but your browser does not support the W3C Level 2 DOM Range API.");
}
}else{
alert("Sorry, but your browser does not support the window.getSelection() method.");
}
}
ahyObjSelectedTextRange constructor
My personal preference is to write object-oriented JavaScript when possible and practical, so, once we've tested for the availability of the getSelection() method and the implementation of the W3C Level 2 DOM Range API, I create an instance of ahyObjSelectedTextRange and assign two of its methods to handle the mousedown and mouseup events respectively and two properties to store pointers to the text nodes from within which the mousedown and mouseup events were fired (ahyMousedownContainer and ahyMouseupContainer respectively). Here's the ahyObjSelectedTextRange constructor function :
ahyObjSelectedTextRange constructor
function ahyObjSelectedTextRange()
{
this.ahyMousedownContainer = null;
this.ahyMouseupContainer = null;
this.ahy_StartSelect = ahy_StartSelect;
this.ahy_EndSelect = ahy_EndSelect;
function ahy_StartSelect(event){
this.ahyMousedownContainer = event.target;
alert("mousedown event.target.nodeValue : \n"+event.target.nodeValue);
}
function ahy_EndSelect(event)
{
this.ahyMouseupContainer = event.target;
alert("mouseup event.target.nodeValue : \n"+event.target.nodeValue);
}
}
For convenience, I've defined the functions that will be ahyObjSelectedTextRange's methods within the constructor function definition, but this is optional. I could just as easily have placed the method function def's outside the constructor function's curly braces without requiring any changes in the internals of the constructor or method functions. Click here to view a demo of the script - if you're using Mozilla or Netscape 6+, clicking will prompt 2 alerts, each containing the value of the node within which you've clicked and released respectively.
We've used the property nodeValue here to get at the contents of the node
window.getSelection()
Next, we'll use Mozilla's window.getSelection() method to collect some information (anchor and focus offsets, its string value, and the length of its string value) about the user's text selection and storing it in properties of our ahyObjSelectedTextRange object :
getSelection()
this.ahySelection = window.getSelection();
this.ahySelectionString = this.ahySelection.toString();
this.ahySelectionLength = this.ahySelectionString.length;
var intSelectionAnchorOffset = this.ahySelection.anchorOffset;
var intSelectionFocusOffset = this.ahySelection.focusOffset;
if(this.ahySelectionLength == 0){
alert("Zero Characters Selected");
return false;
}
After reading Jeff Yates' article, we know that the anchorOffset of a text selection object corresponds to the offset (in number of characters from the beginning of the text in the text node) at which the selection starts (literally - if the user clicks and drags from right-to-left, instead of from left-to-right, when selecting text, then the anchorOffset will correspond to the offset of the right end of the selection, not the left). The focusOffset corresponds to the offset at which the selection ends. If the length of the string value of the selection is zero, then we pop an alert dialog to tell the user to make a non-empty selection and return to stop the execution of the statements in this method.
At this point, you may be thinking that visitors to a site could conceivably want to select some text for copy-and-pasting purposes without triggering Ahoy. We can easily require a modifier key by testing for either one of three modifier key properties of the event object : Alt (event.altKey), Ctrl (event.ctrlKey), and Shift (event.shiftKey). I chose the Alt key because I'm accustomed to using Ctrl+click to open links in new tabs and Shift+clicking to close a large selection of text.
Click here to view a demo of the script - if you're using Mozilla or Netscape 6+, clicking and drag with the Alt key pressed to select some text. When you release the Alt key and mouse button, you'll get an Alert containing the text of your selection.
Right now, with the information that we have, we could use methods of the Range API and Core API to replace the text node containing the selection with an anchor (<a name="ahoyanchor">text selection</a>) element node containing the selected text as a child text node and flanked by 2 text nodes each containing the non selected text of the original text node, but that wouldn't get us to our goal : linkability. We'll need to tack a query string onto the page's url to hold information that can be used by the script to create a named anchor for the text selection when the url is loaded by a visitor to the site and modify the script to check for Ahoy-related parameters once the in the url when the page has loaded. If the script sees these parameters, it should parse the url, extract the information, and create the anchor.
We need to transmit the following via our query string parameters : identifying information about the text node within which the selection was made, the offset of the beginning of the selection (in number of chars from the beginning of the text node), and the length of the selection. Since text nodes don't have any identifying characteristics, so we're going to use their index within the childNodes collection of their parent node and the parent node itself.
If the parent node has an id attribute, then we can use pass the id value in the query string and use document.getElementById() to get a handle on the parent node when preparing to insert the anchor. Otherwise, we use document.getElementsByTagName() and iterate through the array returned by that method until we find the index parent node, which we can pass through the query string.
Below is the section of the script that we use to gather this information. :
this.ahyChildNodeOfParentNodeForAnchorToSeek = this.ahyMouseupContainer;
this.ahyParentNodeTagName = this.ahyParentNodeForAnchor.nodeName;
if(this.ahyParentNodeForAnchor.hasAttribute("ID")){
this.ahySelectionParentNodeElementID = this.ahyParentNodeForAnchor.getAttribute("ID");
for(var i=0; i < this.ahyParentNodeForAnchor.childNodes.length; i++){
if(this.ahyParentNodeForAnchor.childNodes[i] == this.ahyChildNodeOfParentNodeForAnchorToSeek){
if(intIndexMouseEventsNode > 0){
this.ahySelectionThisNodeIndex = intIndexMouseEventsNode
} else{
this.ahySelectionThisNodeIndex = i;
}
break;
}
}
}else{
var arrDocumentGetElementsByTagName = document.getElementsByTagName(this.ahyParentNodeTagName);
elementsloop:for(var i=0; i< arrDocumentGetElementsByTagName.length; i++){
childnodesloop:for(var j=0; j<arrDocumentGetElementsByTagName[i].childNodes.length; j++){
if(arrDocumentGetElementsByTagName[i].childNodes[j] == this.ahyChildNodeOfParentNodeForAnchorToSeek){
this.ahyParentNodeIndex = i;
if(intIndexMouseEventsNode > 0){
this.ahySelectionThisNodeIndex = intIndexMouseEventsNode
} else{
this.ahySelectionThisNodeIndex = j;
}
break elementsloop;
}
}
}
}
In the snippet above, the parent node's id attribut value is sought. If the parent node has an id, then its value is stored in the ahySelectionParentNodeElementID property of the object. The child nodes are examined and the index of the child node that happens to be the text node containing the user selection is stored in the ahySelectionThisNodeIndex property. If no id attribute is found, then the getElementsByTagName route is taken and the index of the parent node within the collection of elements with the same tag name is stored in the ahyParentNodeIndex property and, as in the other case, the child nodes are examined and the index of the text node of interest is stored in ahySelectionThisNodeIndex. Now, we're ready to build our query string.
Ahoy query strings can take two possible forms, depending on whether or not the parent element had an id attribute value. An example of each :
file.htm?ahyAnchor=1&ahyParentNodeID=introp&ahyChildIndex=0&ahySelectionStart=28&ahySelectionLength=17
file.htm?ahyAnchor=1&ahyParentNodeTagName=LI&ahyParentNodeIndex=2&ahyChildIndex=0&ahySelectionStart=11&ahySelectionLength=21
getElementsByTagName(ahyParentNodeTagName).
After creating the query string, we set the window location to the old location + query string. At this point, we'll have to modify the code within the ahy_DoOnLoad function.
ahy_DoOnLoad
function ahy_DoOnLoad()
{
if(window.getSelection){
var blnRangeImplemented = document.implementation.hasFeature("Range", "2.0");
if(blnRangeImplemented){
//----------------------------------------------------
if(location.search.indexOf("ahyAnchor") != -1){
objAhyAnchor = new ahyObjAnchor();
var blnORstrExtractAhoyParamsSubstring = objAhyAnchor.ahy_FetchAhyQueryParamsString("ahoyparams");
if(blnORstrExtractAhoyParamsSubstring != false){
objAhyAnchor.ahyQueryStringParamsString = blnORstrExtractAhoyParamsSubstring;
}else{
alert("Problems were encountered while obtaining the set of Ahoy parameters from the query string. Cannot create anchor.");
return false;
}
var blnparseAhoyParams = objAhyAnchor.ahy_ParseAhyQueryParamsString();
if(blnparseAhoyParams){
var blnAhyAnchorCreated = objAhyAnchor.ahy_CreateAnchor();
location.href = "#ahoyanchor";
}else{
alert("Problems were encountered while parsing the Ahoy parameters in query string. Cannot create anchor.");
return false;
}
}
//----------------------------------------------------
objSelectedTextRange = new ahyObjSelectedTextRange();
document.addEventListener("mousedown", objSelectedTextRange.ahy_StartSelect, false);
document.addEventListener("mouseup", objSelectedTextRange.ahy_EndSelect, false);
}
}
}
I've removed the alert dialogs related to the checks for window.getSelection and the W3C DOM Level 2 Range API - there's no point in antagonizing visitors who aren't using Netscape 6.x+ or other Mozilla-derived browsers. The "new" part of the ahy_DoOnLoad() handler is located between the dashed lines. First, we check for the ahyAnchor parameter in the query string (the search property of the location object), and then, if it's there, proceed to create an instance of ahyObjAnchor, extract our parameter name : value pairs and place them into an associative array stored as the ahyAnchorPropertiesArr property of our ahyObjAnchor instance, and create the anchor, then jump to it by setting the href property of the location object to the anchor name.
Below is the ahy_CreateAnchor method of ahyObjAnchor (I've left the comments in so that you can follow what's going on) :
ahy_CreateAnchor
function ahy_CreateAnchor()
{
//---------------------------------------------------
// First, Get the parent node. The method depends
// on whether the Ahoy query string parameters
// include a parent element id OR a parent element
// tagname and index in getElementsByTagName array.
//---------------------------------------------------
if(this.ahyAnchorPropertiesArr["parentnodeid"] != null){
this.ahySelectionParentNode = document.getElementById(this.ahyAnchorPropertiesArr["parentnodeid"]);
}else if((this.ahyAnchorPropertiesArr["parentnodetagname"] != null)&&(this.ahyAnchorPropertiesArr["parentnodeindex"] != null)){
var arrElementsWithSameTagNameAsParentNode = document.getElementsByTagName(this.ahyAnchorPropertiesArr["parentnodetagname"]);
this.ahySelectionParentNode = arrElementsWithSameTagNameAsParentNode[this.ahyAnchorPropertiesArr["parentnodeindex"]];
}else{
alert("One or more Ahoy parameters necessary to resolve the location of the selection are missing. Exiting.");
return false;
}
//---------------------------------------------------
// Next, get the Child Node of interest
//---------------------------------------------------
this.ahySelectionChildNode = this.ahySelectionParentNode.childNodes[this.ahyAnchorPropertiesArr["childnodeindex"]];
//---------------------------------------------------
// Create Range objects and set their starts and ends
// using data from the Ahoy parameters.
//---------------------------------------------------
//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Create Range object before Selection
//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
var objPreSelectionRange = document.createRange();
objPreSelectionRange.setStart(this.ahySelectionChildNode, 0);
objPreSelectionRange.setEnd(this.ahySelectionChildNode, this.ahyAnchorPropertiesArr["selectionstart"]);
var objPreSelectionRangeContentsString = objPreSelectionRange.toString();
//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Create Range object for Selection itself
//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
this.ahyRangeObj = document.createRange();
this.ahyRangeObj.setStart(this.ahySelectionChildNode, this.ahyAnchorPropertiesArr["selectionstart"]);
var intIndexSelectionEnd = this.ahyAnchorPropertiesArr["selectionstart"] + this.ahyAnchorPropertiesArr["selectionlength"];
this.ahyRangeObj.setEnd(this.ahySelectionChildNode, intIndexSelectionEnd);
this.ahyRangeContentsBeforeAnchor = this.ahyRangeObj.toString();
//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Create Range object after Selection
//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
var objPostSelectionRange = document.createRange();
objPostSelectionRange.setStart(this.ahySelectionChildNode, intIndexSelectionEnd);
var strEntireTextNode = this.ahySelectionChildNode.nodeValue;
var intIndexPostSelectionEnd = strEntireTextNode.length;
objPostSelectionRange.setEnd(this.ahySelectionChildNode, intIndexPostSelectionEnd);
var objPostSelectionRangeContentsString = objPreSelectionRange.toString();
//---------------------------------------------------
// We will be creating a document fragment, appending
// nodes constructed from the ranges as children, and
// then replacing the original node with out fragment.
//---------------------------------------------------
this.ahyDocumentFragmentToInsert = document.createDocumentFragment();
//---------------------------------------------------
// Create some text nodes to contain the snips of the
// original text node on either side of the selection.
//---------------------------------------------------
var nodePreSelectionTextNode = document.createTextNode(objPreSelectionRange.toString());
var nodePostSelectionTextNode = document.createTextNode(objPostSelectionRange.toString());
//---------------------------------------------------
// Create our anchor element, give it a name and an id
// and fill it with a text node containing the selected text.
//---------------------------------------------------
var nodeAnchorElement = document.createElement("A");
var nodeAnchorElementContents = document.createTextNode(this.ahyRangeObj.toString());
nodeAnchorElement.setAttribute("name", "ahoyanchor" );
nodeAnchorElement.setAttribute("id", "ahoyanchorid" );
nodeAnchorElement.appendChild(nodeAnchorElementContents);
//---------------------------------------------------
// Append the 3 nodes to the document fragment in
// consecutive order
//---------------------------------------------------
this.ahyDocumentFragmentToInsert .appendChild(nodePreSelectionTextNode);
this.ahyDocumentFragmentToInsert .appendChild(nodeAnchorElement);
this.ahyDocumentFragmentToInsert .appendChild(nodePostSelectionTextNode);
//---------------------------------------------------
// Replace the text node containing the selection with
// the document fragment that we've prepared.
//---------------------------------------------------
this.ahySelectionParentNode.replaceChild(this.ahyDocumentFragmentToInsert, this.ahySelectionParentNode.childNodes[this.ahyAnchorPropertiesArr["childnodeindex"]]);
//---------------------------------------------------
// Get a handle on the ahoyanchor A element so that we
// can manipulate its style properties.
//---------------------------------------------------
this.ahyInsertedAnchorNode = document.getElementById("ahoyanchorid");
this.ahyInsertedAnchorNode.style.backgroundColor="rgb(220,220,220)";
}
Click here to view a demo of the script.
To exactly what's happening in terms of the DOM, open the example in Mozilla's DOM Inspector (Tools > Web Development > DOM Inspector). Use the DOM Node picker (click on the button to the left of the location field and then click anywhere in the text) to pick a text node and the Document - DOM Nodes pane on the left will expand to reveal the node which you've chosen. Then Alt+Click, Drag to select a text fragment and repeat the process of selecting a text node with the DOM Node picker, but click within the grayed text this time. You can see how the original text node containing the selection has been manipulated (before and after).
If, after creating your first Ahoy anchor from a text selection, you attempted to make another Ahoy selection within or between the text nodes (including the one within our Ahoy anchor) that were created in the process of making the existing Ahoy anchor OR within a text node that's a sibling of that original text node, you'd have received either an error message in an alert dialog or the Ahoy anchor looked nothing like your text selection. Although the current working version of Ahoy deals with these changes in document structure without complaint, I left the handling of these cases out of these examples in hopes of keeping the code a bit simpler and easier to follow.
Click here to see the current working version of Ahoy. It's available under the GPL, so click here to download the zipfile. Click here to see an example of a working Ahoy link (in this case, to the phrase "they would desire to live on crutches till he had one").
A few words on where it might and might not be appropriate to use Ahoy. Appropriate : documents that will most likely not be edited after publication. Inappropriate : documents that will change after publication. Obviously, since we're referencing a piece of text by (a.) its offset and length within its text node, (b.) the index of the selection's text node within the childNodes collection of its parent, and (b.) EITHER the index of the text node's parent element node within the collection of element nodes returned by getElementsByTagName() OR the ID attribute value of the text node's parent node, existing Ahoy links to documents can be broken by editing. Changes to a document can break a given Ahoy link when :
The only way to proof your documents against the disruptive effects of editing for the purposes of preserving inbound Ahoy links, then, would be to wrap each text node in its own element node with an id attribute and value.
Insofar as Ahoy has any bugs, I've found that, while the offsets provided by Mozilla's select object (anchorOffset and focusOffset) seem to ignore multiple spaces within selections, treating them, for counting purposes, as single chars, the toString() method of the Range object, as implemented in Mozilla, respects multiple spaces in the source, causing the length of the text child nodes within the Ahoy anchors created from text selections containing multiple spaces to be shorter by the the sum of the number of consecutive multiple spaces minus one. This is something that can be dealt with, but I haven't tackled it yet.
As I stated at the outset, Ahoy employs only a few lines of Mozilla-specific code (where window.getSelection() and properties of Mozilla selection objects are used to get at the user's selection). Porting Ahoy to other browsers that support the relevant portions of the W3C DOM and provide their own means of getting a handle on user selections equivalent to that provided by Mozilla is a simple matter of (1.) checking for the availability of their text-selection-grabbing method in ahy_DoOnLoad when we check for window.getSelection() and (2.) branching to Mozilla-specific and BrowserX-specific selection-getting code in ahy_EndSelect.
That being said, even with version 6 of their browser, Microsoft does not implement the W3C DOM Level 2 Events Recommendation in Internet Explorer and their own events implementation does not provide functionality that's available in browsers that do adhere to W3C DOM 2 Events and necessary for our purposes. The target property of the event object in W3C DOM 2 Events points to the actual text node in which the event originates, but the equivalent property in MSIE's events implementation, srcElement, as the name implies, points to the element containing the node in which the event originates. When both the srcElement property of both the mousedown and mouseup events point to an element node whose childNodes collection contains only a single node of type text, we can surmise that it must be that text node which contains the selection, but we will often be faced with an element node containing several text nodes. The W3C DOM Level 2 Range API isn't implemented in any version of Internet Explorer either. It should be noted that Internet Explorer offers a document.selection object that can be used to generate a TextRange object using a createRange() method. Unfortunately, however, they both appear to use pixels, not integer numbers of characters, for all dimensions, including offsets.
In the end, although I'm sure that it would be possible (one way or another) to achieve functionality equivalent to that furnished by Ahoy in Mozilla for MSIE, the prospect of casting the W3C DOM by the wayside in favor of MSIE-specific alternatives and possibly endless hacks fills me with horror and I'm content, for now, to use Ahoy to provide added functionality for visitors to my site using Mozilla and NS 6.x+.
top