Skip to content

Scrolling to Cross-browser Ranges

One 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 having to do with ranges, there's no consistent way to do it, and Internet Explorer does it best (this is the only time you'll hear me say that).

For IE, the TextRange object supports scrollIntoView, so all you need to do is:

range.scrollIntoView()

and you're done.

In bililiteRange I don't actually use this, since scrollIntoView() scrolls to the top of the screen no matter what, and I want to only scroll if the range isn't already visible. To do that, I need to set the scrollTop of the element appropriately; the formula is:

var top = range.boundingTop - element.offsetTop + 2*element.scrollTop;

The double adding element.scrollTop is a documented IE bug. Then we can test to scroll if needed:

if (element.scrollTop > top || element.scrollTop+element.clientHeight < top){
  element.scrollTop = top;
}

Standards-based browsers use Range objects, and they have no scrollIntoView. But we can create a dummy element, insert it into the range, and scroll to that:


// Note: don't do this! It messes up the selection
var span = document.createElement('span');
range.insertNode(span);
span.scrollIntoView();
span.parentNode.removeChild(span);

Edit 2014-01-09: It turns out that there's odd behavior with the selection if you modify the DOM. While the above approach is simple if that's not a problem, the right way is with (where element is the parent element that does the scrolling):

var top = range.getBoundingClientRect().top - element.offsetTop + element.scrollTop;

And set element.scrollTop if necessary, as above.

If the range is empty, you have to temporarily insert some text to make it have a clientRect, os the actual code is:

if (range.toString() == ''){
	var textnode = this._doc.createTextNode('X');
	rng.insertNode (textnode);
}
var top = range.getBoundingClientRect().top - element.offsetTop + element.scrollTop;
if (textnode) textnode.parentNode.removeChild(textnode);

This method fails, unfortunately, if the parent element is absolutely positioned. As of 2014-01-13, I use an easier method that creates a dummy range at the beginning of the element and subtract its getBoundingClientRect().top from that of the target range. See the bililiteRange source for IERange.prototype._nativeTop() and W3CRange.prototype._nativeTop() for details

But the hardest part is scrolling a <textarea>. There's no good way to scroll to a selection in a text area, and there's no equivalent of getBoundingClientRect so we can use scrollTop to scroll to the right place. I don't know where I found this clever hack, but it solves that problem: scrollHeight gives the total height of the text in a <textarea> if it overflows, so we can set the height to 1px (forcing the overflow), truncate the value to the text before the range we care about, and measure scrollHeight. This will be the bottom of the beginning of the range, in pixels from the top of the <textarea>. We want the top of the range, so subtract the line height (which we get as the scrollHeight of a one-line <textarea>):

var style = getComputedStyle(textareaElement);
var oldheight = style.height;
var oldval = textareaElement.value;
textareaElement.style.height = '1px';
textareaElement.value = oldval.slice(0, rangeStart);
var top = textareaElement.scrollHeight;
textareaElement.value = 'X';
top -= textareaElement.scrollHeight;
textareaElement.style.height = oldheight; // restore the element before anyone notices. May have a brief flash
textareaElement.value = oldval;
// scroll into position if necessary as above
if (textareaElement.scrollTop > top || textareaElement.scrollTop+textareaElement.clientHeight < top){
	textareaElement.scrollTop = top;
}

Now the <textarea> is scrolled correctly.

But we may still want to make sure that the element itself is scrolled into view. We can't just use scrollIntoView straight, since that shows the top of the element, and the range may be at the bottom. In bililiteRange I don't worry about that (bililiteRange.scrollIntoView() only scrolls within the element). If you want to take care of that, create a dummy element as above, absolutely position it at the desired location, and scroll to that:

// get the position as in jQuery.offset
var rect = textareaElement.getBoundingClientRect();
rect.top += window.pageYOffset - document.documentElement.clientTop;
rect.left += window.pageXOffset - document.documentElement.clientLeft;
 // create an element to scroll to
var div = document.createElement('div');
div.style.position = 'absolute';
div.style.top = (rect.top+top-textareaElement.scrollTop)+'px'; // adjust for how far in the range is; it may not have scrolled all the way to the top
div.style.left = rect.left+'px';
div.innerHTML = '&nbsp;'; // scrollIntoViewIfNeeded won't scroll if there's no content (it's not needed!)
document.body.appendChild(div);
div.scrollIntoViewIfNeeded ? div.scrollIntoViewIfNeeded() : div.scrollIntoView();
div.parentNode.removeChild(div);

For <textarea>s you may also want to save the original selection with selectionStart and selectionEnd and restore it with setSelectionRange, since it will change when the text is truncated. In bililiteRange I create a dummy element that is a clone of the original, so the selection doesn't change.

And this ugly mess seems to work correctly. Hope some of this analysis is useful to someone.

{ 2 } Trackbacks

  1. […] Hacking at 0300 : Scrolling to Cross-browser Ranges | February 13, 2013 at 5:54 pm | Permalink […]

  2. […] it turns out adding and removing elements messes up the selection. So I had to change it to manipulate scrollTop […]

Post a Comment

Your email is never published nor shared. Required fields are marked *