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.

15 Comments

  1. jose mauricio says:

    Hi, first of all, congratulations for this wonderfull library. It’s been very helpfull.

    I have an issue: when using google chrome browser, i noticed that i allways have the current position of the caret inside the text but, when using ie (IE8), i only get the current position if i click on the element, after that, it will allwais return the caret position as if the caret was at the end of the text. I’m using ie8 and the test was made on a div with contendeditable set to true. Any ideias on how could i achieve the same behaviour Google Chrome has?

  2. Danny says:

    @Jose:
    Unfortunately, IE does not record the selection location. I’ve played with this for a bit and do not have an answer. The selection is lost, as I recall, before the blur event is fired, so that doesn’t help. The only thing I can think of is catching the change and click events and constantly recording the selection. Let me know if you’ve got something that works!
    –Danny

  3. jose mauricio says:

    Hi, thanks a lot for the quick response. Catching the change and click events and constantly recording the selection? I guess that’s what i’ll have to do. Thanks!

  4. Hadar says:

    thanks for the great plugin.
    I found a bug though, where it was inserting the text only to the begining of the contenteditable div.
    the textProp function should have the line “el.text” removed, since el.text is not related to the content but rather to the color.
    removing the line solved the issue.

  5. Danny says:

    @Hadar:
    I’m not sure what you mean. I’m getting the text inserted correctly with the code as is. What browser are you using?
    –Danny

  6. Hadar says:

    i’m using chrome, the .text property is only for ie.

  7. Danny says:

    @Hadar:
    The demo is working for me in Chrome (Windows, 17.0.963.79 m). The code is supposed to be smart enough to detect which property is appropriate for each browser. Do you have a sample page online that shows the error?
    –Danny

  8. Jochen says:

    Hi, nice work! Especially in combination with the sendkeys plugin. However, I found a bug with Opera on Windows: after calling the insertEOL() function, the selection is not behind the EOL marker. In combination with the sendkeys plugin, this results in the EOL markers being inserted at the end of the text. It is also not possible to move over the EOL markers using “{rightarrow}” (while “{leftarrow}” is working). I guess the problem is the following: Opera provides the window.getSelection() and .setSelectionRange() functions, hence bililiteRange uses the InputRange class. But Opera converts UNIX line delimiters (“\n”) to Windows line delimiters (“\r\n”) when changing the value and bililiteRange seems to not take care of this. I tried to fix this but I’m not enough into this whole selection thing to figure out where to take care of the “\r”. Any suggestions?

  9. Danny says:

    @Jochen:
    As I noted in the post, insertEOL is flaky since browsers are so inconsistent. Thanks for your detective work on finding the source of the problem in Opera. Unfortunately I don’t have much time for fixing this, since it does everything I need it to. And I rarely test in Opera. It tries so hard to be standards-compliant and pretend to be Internet Explorer that you have to special-case everything, and it is so rarely used.

    You’d have to do some testing at the start by creating an input element, set its value to ‘\n’ and see if it changed it to ‘\r\n’ then add the appropriate logic (similar to what I do for IE) to remove the ‘\r’. Good luck.

    –Danny

  10. Dileep.M says:

    There is a bug in BililiteRange with IE .

    It’s Sending Keys at the End of the Text when the cursor was moved via Arrow Keys. But It’s okay when moved via Mouse.

    Commenting the following line is producing desired output. (Keys at the Cursor position).

    Till Now No Side Effects found. Please update any other purpose of the following code.

    The following is responsible for that
    if (document.selection.type != ‘Text’) return [len, len]; // append to the end

  11. Hacking at 0300 : Scrolling to Cross-browser Ranges says:

    […] thing I wanted to add to my bililiteRange was the ability to scroll the range into view without changing the selection. As with all things […]

  12. Hacking at 0300 : Github! says:

    […] the original bililiteRange (for which I know there are some bugs, especially in IE and Opera; another advantage of github is […]

  13. Hacking at 0300 : Simple syntax-highlighting editor with Prism says:

    […] be used on the fly for active editing; Lea Verou (the author) does this with Dabblet. Combined with bililiteRange to handle some of the subtleties of dealing with contenteditable elements, a syntax-highlighting […]

  14. Hacking at 0300 : Odd bug with document ranges and selections says:

    […] use Range and Selection extensively in bililiteRange in regular elements in standards-based browsers. I initially implemented scrolling a range by […]

  15. Hacking at 0300 : Thoughts on Test Driven Development says:

    […] was just working on adding autoindenting to bililiteRange, and actually took advantage of the fact that I had an automated test harness in place for that […]

Leave a Reply


Warning: Undefined variable $user_ID in /home/public/blog/wp-content/themes/evanescence/comments.php on line 75