Skip to content

Cross-Browser Text Ranges and Selections

Updated to version 2.3 on 2014-02-26 for data routines.

I was trying to update my sendkeys plugin to use contentEditable, and realized that it was getting harder and harder to do without some selection/text replacement library. Each browser is different and even different elements are treated differently. There are a few libraries out there, but none handled both input and textarea elements and regular elements, and would constrain the selections to a given element (critical if I want to edit only part of a document) and none made it easy to simulate arrow keys, moving the selection left or right. So I figured I would make my own.

See the code on Github.

See the demo (try it in different browsers!).

I was entering a world of hurt.

But I think I survived, with a great deal of knowledge about browsers and a useful package of routines.

Usage

bililiteRange(element) returns an abstraction of a range of characters within element, initially all of the element. The range never extends outside the element. element is a DOM element. jQuery is not required or used for this; it involved too much under-the-hood hacking that it was easier to write everything myself.

Note that this fails for the entire <body> element. I know why, but I don't have the time to fix it, and I haven't used it. If there's any demand, please let me know.

The object exposes the following methods:

all()
Returns the entire text of the element without changing the bounds of the range.
all(text {String})
Sets the entire text of the element to text without changing the bounds of the range.
bounds()
Returns an Array [start, end] of the bounds of the current range. start is always >= 0 and end is always <= length of the text of the entire element.
bounds(Array | 'all' | 'selection')
Sets the bounds of the current range to the Array [start, end], limited to [0, length of whole element]. Does not throw an error for limits out of bounds, just silently limits it. bounds('all') sets the range to cover the entire element. bounds('selection') sets the range to the part of the current selection that is in the element. This only uses the actual selection if the element is the same as document.activeElement; if the element is not active, then bililiteRange sets up event listeners to remember the selection from when the element was the active element, and uses that. If the element was never active, it does its best: if the selection is before the element, returns [0,0] and if the selection is after the element, returns [length of whole element, length of whole element]. See my discussion of preserving the selection on blur.
clone()
Return a new bililiteRange with the same bounds as this one.
data()
Returns an object tied to the underlying element. See my later post on bililiteRange data.
element()
Returns the DOM element that the range was defined on.
insertEOL()
Replaces the text at the current range with a line break. For input and textarea elements, this is the same as text('\n'). For other elements, this inserts a br element.
This part remains flaky for contenteditable elements. I can't get the standards-based browsers to select <br> elements consistently. Use at your own risk (or fix it for me and let me know!)
scrollIntoView([Function])
Does its best to scroll the beginning of the range into the visible part of the element, by analogy to Element.scrollIntoView. See my post for a longer discussion. Note that it does not move the element itself, just sets element.scrollTop so that the start of the range is within the visible part of the element. If it already visible, does nothing. This only scrolls vertically, not horizontally.
The function passed in used to do the scrolling, with one parameter that is the target scrollTop, and this set to the element itself. So, to animate the scrolling, use range.scrollIntoView(function (top) { $(this).animate({scrollTop: top}) }). The default function is function (top) { this.scrollTop = top }.
select()
If the element is the same as document.activeElement, then set the window selection to the current range. If the window is not active, change the saved selection to the current range, and use that for bounds('selection'). Sets up event listeners so that when the element is activated, the saved selection is restored. See my discussion of preserving the selection on blur.
Note that this does not set the focus on the element; use range.element().focus() to do that. Note also that elements that are not editable and do not have a tabindex cannot be focussed.
selection()
Short for rng.bounds('selection').text(), to get the text currently selected.
selection(String)
Short for rng.bounds('selection').text(text, 'end').select(); useful for inserting text at the insertion point. This just inserts the String argument straight in the text; for a more sophisticated function, see sendkeys below.
sendkeys(String)
Basically does text(String, 'end') but interprets brace-surrounded words (like '{Backspace}' as special commands that execute the corresponding functions in bililiteRange.sendkeys, in this case bililiteRange.sendkeys['{Backspace}']. See the description of the functions available and how to add more.
text()
Returns a String containing the text of the current range (with '\r\n' converted to '\n').
text(String [, 'start'|'end'|'all'])
Sets the text of the current range to String. If the second argument is present, also sets bounds, to the start of the inserted text, the end of the inserted text (what happens with the usual "paste" command) or the entire inserted text. Follow this with select() to actually set the selection.
top()
Returns the "offsetTop" for the range--the pixels from the top of the padding box of the element to the beginning of the range. See MSDN's description of element dimensions. Will be negative if the element is scrolled so that the range is above the visible part of the element. To scroll the element so that the range is at the top of the element, set element.scrollTop = range.top(). See range.scrollIntoView() above, and my extended discussion.
wrap(Node)
Wraps the range with the DOM Node passed in (generally will be an HTML element). Only works with ranges defined on the DOM itself; throws an error for ranges in <input> or <textarea> elements. Depending on the browser, will throw an error for invalid HTML (like wrapping a <p> with a <span>). For example, to highlight the range, use rng.wrap (document.createElement('strong'));

Examples

Replace the first character of an element: bililiteRange(element).bounds([0,1]).text('X')

Select all of an element: bililiteRange(element).bounds('all').select()

Implement a "backspace" key on an editable element (assuming the element is focused and the selection has been made by the user):

var rng = bililiteRange(element).bounds('selection');
var bounds = rng.bounds();
if (bounds[0] == bounds[1]){
  // no characters selected; it's just an insertion point. Remove the previous character
  rng.bounds([bounds[0]-1, bounds[1]]);
}
rng.text('', 'end'); // delete the characters and replace the selection

Implement a "left arrow" key on an editable element:

var rng = bililiteRange(element).bounds('selection');
var bounds = rng.bounds();
if (bounds[0] == bounds[1]){
  // no characters selected; it's just an insertion point. Move to the left
  rng.bounds([bounds[0]-1, bounds[0]-1]);
}else{
  // move the insertion point to the left of the selection
  rng.bounds([bounds[0], bounds[0]]);
}
rng.select();

Plugins

Inspired by jQuery, I exposed the prototype for the underlying object so it is possible to extend bililiteRange. bililiteRange.extend({myplugin: function(){ do things where this is the bililiteRange object; notably this._el is the original element }; }). For instance, you could create the plugin:

bililiteRange.extend({
	line: function(n){
		var text = this.bounds('all').text();
		var lineRE = /^.*$/mg; // a whole line
		var match;
		var bounds = [0,0];
		for (var i = 0; i < n; ++i){
			// note that we are using 1-indexed lines! Line 0 selects the start of the text; lines past the end select the last line.
			match = lineRE.exec(text);
			if (match) bounds = [match.index, match.index+match[0].length]; // the whole line
			if (!match) break;
		}
		this.bounds(bounds);
		return this;
	}
});

And use this to select lines:

<pre id=text tabindex=0 >Lorem ipsum dolor sit amet, consectetur adipiscing elit. In bibendum tincidunt diam,
 ac consequat turpis gravida id. Sed eget faucibus leo. Ut aliquet, sem nec porttitor
 rhoncus, felis nisi aliquet eros, a ornare nisl velit eget massa. Fusce dui leo, tempus
 quis auctor et, lobortis tincidunt ante. Quisque pretium rutrum suscipit. Nam id sapien
 vitae sapien scelerisque dignissim venenatis nec risus. Integer vitae enim ut orci dapibus
 lobortis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur lectus libero,
 scelerisque eu sodales vitae, convallis scelerisque justo. Maecenas vitae dictum enim. Sed
 hendrerit magna vitae eros aliquet vestibulum.</pre>
<label>Line Number: <input id=linenumber /></label><input type=submit value="Select Line" id=selectline />
$('#selectline').click(function(){
	var n = parseInt($('#linenumber').val());
	if (!isNaN(n)) bililiteRange($('#text')[0]).line(n).select().element().focus();
});

bililiteRange.fn.myplugin = function(){} also works (as in jQuery) and can be used for monkey patching:

var oldbounds = bililiteRange.fn.bounds;
bililiteRange.fn.bounds = function () {console.log(this._bounds); oldbounds.apply(this, arguments);}

Events

This tries to implement DOM level 3 events. rng.text('some text') fires a beforeinput event before, and an input event after, setting the text. The event object passed to event handlers has two extra fields: event.data is 'some text' that is to be or was inserted. event.bounds is set to the bounds of the range before the text was inserted (this is not part of the standard).

rng.select() fires a select event on the element.

The events are asynchronous in the sense that they are dispatched on the event loop, not executed immediately.

Note that jQuery uses the event.data field for its own purposes (to send the data that was added in the $element.on() handler). If that data is present, then the input text is not passed in at all. You can use event.originalEvent to get my input event

My workplace is still standardized on Internet Explorer 8, so I have tried to make these routines compatible. The best way to do that is with a polyfill. In the absence of that, the code tries to use my own quick and dirty polyfill. It also tries to polyfill input events by listening for keyup, cut, paste and drop events and triggering input events.

The IE8 code event handling code works very poorly, and I will not be spending a lot of time fixing it. I hope to be rid of it soon.

It exposes three utility routines for event handling (each called as bililiteRange(element).listen('input', function) etc.):

dispatch(opts {Object})
Asynchronously creates a custom event of type opts.type, then extends it with the rest of opts, and dispatches it on rng.element. Basically does:
var event =  new CustomEvent(opts.type);
for (var key in opts) event[key] = opts[key];
this.element().dispatchEvent(event);
but with setTimeout(..., 0);.
listen(String, Function)
Shorthand for this.element().addEventListener.
dontlisten(String, Function)
Shorthand for this.element().removeEventListener.

Implementation Notes

I never thought I'd say this, but I think Internet Explorer's API is better than the standards-based one (remember, IE did it all earlier than anyone else; they just didn't bother keeping up with the competition). moveStart and moveEnd make life much easier when I'm trying to manipulate ranges character-by-character.

Neither model lets you play with characters directly; I had to iterate through the page. Again, IE lets you do it character by character, while the standards way required traversing the DOM tree and counting characters in text nodes. In case you are interested, here's an in-order depth-first traversal without recursion (I hate getting "too much recursion" errors):

function nextnode (node, root){
	if (node.firstChild) return node.firstChild;
	if (node.nextSibling) return node.nextSibling;
	if (node===root) return null;
	while (node.parentNode){
		node = node.parentNode;
		if (node == root) return null;
		if (node.nextSibling) return node.nextSibling;
	}
	return null;
}

As I write this, I note the existence of nodeIterator, which does the same thing. I may try it out in the future. It's only available in Firefox 3.5 and above.

Internet Explorer is still weird. createTextRange exists for body, textarea, and input elements, but it doesn't work correctly on textarea elements, so I have to switch on the tagName and use rng = document.body.createTextRange (); rng.moveToElementText(el); for most elements. Then, for display: block elements, there's an invisible paragraph marker that gets selected but isn't in the textrange.text string, so moving to the end involves an extra moveEnd('character', -1), then recalculating the size of the text.

And calculating the size of the text is no easy feat, since Internet Explorer uses carriage return-line feeds to end lines in textarea elements. I use len = range.text.replace(/\r/g, '').length to calculate the length.

The standards-based browsers handle input elements with an entirely different API, with setSelectionRange to do exactly what I'm trying to do: manage ranges as character positions. If only everything was this easy; now it's just another special case I have to test for.

textarea elements are consistently inconsistent: they have textnode children like real elements, but their text in in the value attribute like input elements. So there's a lot of switching on tagNames.

Internet Explorer's a.compareEndPoints(how, b) and the standard's a.compareBoundaryPoints(how, b) sound identical, but they interpret their arguments in exactly the opposite way. This is insane! IE's start-to-end compares a's start to b's end, but standard's start-to-end compares b's start to a's end. Both interpret the result the same way, -1 if a is before b, +1 if a is after b. Again, IE seems more intuitive.

The standards API throws errors for any out-of-bounds violations (offsets too large or < 0, start > end). IE just accepts them and limits them appropriately, which is the tack I take here.

Hope this turns out useful to someone.

{ 10 } Comments


  1. Fatal error: Uncaught Error: Call to undefined function ereg() in /home/public/blog/wp-content/themes/barthelme/functions.php:178 Stack trace: #0 /home/public/blog/wp-content/themes/barthelme/comments.php(34): barthelme_commenter_link() #1 /home/public/blog/wp-includes/comment-template.php(1469): require('/home/public/bl...') #2 /home/public/blog/wp-content/themes/barthelme/single.php(44): comments_template() #3 /home/public/blog/wp-includes/template-loader.php(74): include('/home/public/bl...') #4 /home/public/blog/wp-blog-header.php(19): require_once('/home/public/bl...') #5 /home/public/blog/index.php(17): require('/home/public/bl...') #6 {main} thrown in /home/public/blog/wp-content/themes/barthelme/functions.php on line 178