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 andend
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 asdocument.activeElement
; if the element is not active, thenbililiteRange
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
andtextarea
elements, this is the same astext('\n')
. For other elements, this inserts abr
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
, andthis
set to the element itself. So, to animate the scrolling, userange.scrollIntoView(function (top) { $(this).animate({scrollTop: top}) })
. The default function isfunction (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 forbounds('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 atabindex
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, seesendkeys
below. sendkeys(String)
- Basically does
text(String, 'end')
but interprets brace-surrounded words (like'{Backspace}'
as special commands that execute the corresponding functions inbililiteRange.sendkeys
, in this casebililiteRange.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 withselect()
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()
. Seerange.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, userng.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 ofopts
, and dispatches it onrng.element
. Basically does:
but withvar event = new CustomEvent(opts.type); for (var key in opts) event[key] = opts[key]; this.element().dispatchEvent(event);
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 tagName
s.
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.
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?
October 11, 2011, 8:18 amDanny says:
@Jose:
October 11, 2011, 9:00 amUnfortunately, 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
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!
October 11, 2011, 9:18 amHadar says:
thanks for the great plugin.
February 2, 2012, 6:33 amI 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.
Danny says:
@Hadar:
February 2, 2012, 11:24 pmI’m not sure what you mean. I’m getting the text inserted correctly with the code as is. What browser are you using?
–Danny
Hadar says:
i’m using chrome, the .text property is only for ie.
March 15, 2012, 10:51 amDanny says:
@Hadar:
March 15, 2012, 9:54 pmThe 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
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?
September 3, 2012, 5:37 pmDanny 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
September 3, 2012, 6:45 pmDileep.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
December 13, 2012, 6:21 amif (document.selection.type != ‘Text’) return [len, len]; // append to the end
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 […]
February 13, 2013, 5:54 pmHacking 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 […]
March 14, 2013, 8:57 amHacking 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 […]
December 16, 2013, 4:01 pmHacking 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 […]
January 9, 2014, 2:40 pmHacking 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 […]
February 23, 2014, 7:56 am