Creating a collapsed range from a pixel position in FF/Webkit

Using JavaScript, I would like to create a collapsed range from a pixel position, in order to insert new nodes in the flow of the document, after the range identified by this position.

This can be done with the TextRange object in Internet Exporer (moveToPoint(x, y) method).

How can I do this in FireFox & Webkit?

I can get the container element from the position with document.elementFromPoint(x, y). But when the position happens to be inside a text node, how do I get more information about the text offset required to build a range?


Solution 1:

Here is my implementation of caretRangeFromPoint for old browsers:

if (!document.caretRangeFromPoint) {
    document.caretRangeFromPoint = function(x, y) {
        var log = "";

        function inRect(x, y, rect) {
            return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
        }

        function inObject(x, y, object) {
            var rects = object.getClientRects();
            for (var i = rects.length; i--;)
                if (inRect(x, y, rects[i]))
                    return true;
            return false;
        }

        function getTextNodes(node, x, y) {
            if (!inObject(x, y, node))
                return [];

            var result = [];
            node = node.firstChild;
            while (node) {
                if (node.nodeType == 3)
                    result.push(node);
                if (node.nodeType == 1)
                    result = result.concat(getTextNodes(node, x, y));

                node = node.nextSibling;
            }

            return result;
        }

        var element = document.elementFromPoint(x, y);
        var nodes = getTextNodes(element, x, y);
        if (!nodes.length)
            return null;
        var node = nodes[0];

        var range = document.createRange();
        range.setStart(node, 0);
        range.setEnd(node, 1);

        for (var i = nodes.length; i--;) {
            var node = nodes[i],
                text = node.nodeValue;


            range = document.createRange();
            range.setStart(node, 0);
            range.setEnd(node, text.length);

            if (!inObject(x, y, range))
                continue;

            for (var j = text.length; j--;) {
                if (text.charCodeAt(j) <= 32)
                    continue;

                range = document.createRange();
                range.setStart(node, j);
                range.setEnd(node, j + 1);

                if (inObject(x, y, range)) {
                    range.setEnd(node, j);
                    return range;
                }
            }
        }

        return range;
    };
}

Solution 2:

Here is the result of my investigation for getting a character position inside a text node from a pixel position:

  • The standardized way: Get a range from a position with document.caretRangeFromPoint(x, y) See the spec at W3c. This is exactly what I was looking for. The problem is that Chrome is the only web browser that implements this method as of this writing (July 2010)
  • The MS IE way with the proprietary textRange.moveToPoint(x, y).
  • The Firefox way: If the pixel position (x, y) is retrieved from a mouse event, then Firefox will add two useful properties to the event object: rangParent and rangeOffset
  • For Safari & Opera (and actually the only cross-browser method) is to re-constitute the containing boxes for text nodes, and then use the pixel position inside the containing box to infer the character position. To do this you must:
    1. Wrap all text nodes into <span> elements (dimension information is only available for elements, not for text nodes)
    2. Call span.getClientRects() to get the containing boxes for each textNode (wrapped into a <span>). If the text node spans over several lines you'll get several boxes.
    3. Find the box which contains your (x, y) pixel position, and infer the character position with a simple "rule of three" based on the total pixel width and text length.

Solution 3:

Under MSIE, you wrote:

var range = document.selection.createRange();
range.moveToPoint(x, y); 

For other browsers, the idea is to determine the HTML element at x/y position and to create a one character selection on it. Based on range.getBoundingClientRect(), you can determine if the one character selection if before or after the x/y position. We then can select the next character until the selection position raich the x/y position. I wrote the following implementation for Firefox, Safari and Chrome:

var nodeInfo = getSelectionNodeInfo(x, y);
var range = document.createRange();
range.setStart(nodeInfo.node, nodeInfo.offsetInsideNode);
range.setEnd(nodeInfo.node, nodeInfo.offsetInsideNode);

/**
Emulates MSIE function range.moveToPoint(x,y) b
returning the selection node info corresponding
to the given x/y location.

@param x the point X coordinate
@param y the point Y coordinate
@return the node and offset in characters as 
{node,offsetInsideNode} (e.g. can be passed to range.setStart) 
*/
function getSelectionNodeInfo(x, y) {
    var startRange = document.createRange();
    window.getSelection().removeAllRanges();
    window.getSelection().addRange(startRange);

    // Implementation note: range.setStart offset is
    // counted in number of child elements if any or
    // in characters if there is no childs. Since we
    // want to compute in number of chars, we need to
    // get the node which has no child.
    var elem = document.elementFromPoint(x, y);
    var startNode = (elem.childNodes.length>0?elem.childNodes[0]:elem);
    var startCharIndexCharacter = -1;
    do {
        startCharIndexCharacter++;
        startRange.setStart(startNode, startCharIndexCharacter);
        startRange.setEnd(startNode, startCharIndexCharacter+1);
        var rangeRect = startRange.getBoundingClientRect();
    } while (rangeRect.left<x && startCharIndexCharacter<startNode.length-1);

    return {node:startNode, offsetInsideNode:startCharIndexCharacter};
}

These two piece of code have been tested under :

  • MSIE 7, MSIE 9
  • Firefox 5, Firefox 10
  • Chrome 9
  • Safari 5

The following situations were not tested:

  • zooming factor issues
  • HTML elements with more than one text line