Creating a collapsed range from a pixel position i

2019-01-07 15:23发布

问题:

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?

回答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;
    };
}


回答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.


回答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


回答4:

The situation has changed since this question and most of the answers were posted: all major browsers now have at least one of the methods that make this relatively simple:

  • The standards-based approach from the CSSOM View spec: document.caretPositionFromPoint()
  • WebKit's proprietary version of the same: document.caretRangeFromPoint()
  • IE's proprietary TextRange object, which has a moveToPoint() method that takes pixel coordinates. However, it seems that moveToPoint() can be buggy (see here and here, for example); I've simply been lucky that has worked in all the documents I've used it in.

Note that in IE up to and including version 11, the object produced is a TextRange rather than a DOM Range. In versions of IE that support Range, there is no easy way to convert between the two, although if you're willing to mess with the selection you can do something like the following, assuming you have a TextRange stored in a variable called textRange:

textRange.select();
var range = window.getSelection().getRangeAt(0);

Here's some example code. It works in IE 5+, Edge, Safari and Chrome from around 2010 onwards, Firefox >= 20 and Opera >= 15.

Live demo: http://jsfiddle.net/timdown/rhgyw2dg/

Code:

function createCollapsedRangeFromPoint(x, y) {
    var doc = document;
    var position, range = null;
    if (typeof doc.caretPositionFromPoint != "undefined") {
        position = doc.caretPositionFromPoint(x, y);
        range = doc.createRange();
        range.setStart(position.offsetNode, position.offset);
        range.collapse(true);
    } else if (typeof doc.caretRangeFromPoint != "undefined") {
        range = doc.caretRangeFromPoint(x, y);
    } else if (typeof doc.body.createTextRange != "undefined") {
        range = doc.body.createTextRange();
        range.moveToPoint(x, y);
    }
    return range;
}


回答5:

An extension of Julien's answer above. This copes with multiple lines. Needs a little tweaking, but seems to work. It finds the number of lines by getting the height of a start to end selection, and the height of a single-letter selection, dividing the two, and rounding. There are probably situations where that won't work, but for most purposes...

function getLineCount(node, range) {
    if ((node) && (range)) {
        range.setStart(node, 0);
        range.setEnd(node, 1);
        var r = range.getBoundingClientRect();
        var h1 = r.bottom - r.top;
        range.setEnd(node, node.length);
        r = range.getBoundingClientRect();
        return Math.round((r.bottom - r.top) / h1);
    }
};

Here's a tweaked version of the above code, using the line-count routine above. It also copes a little better with selections within the node, but off to the right of the actual text. None of this is optimized, but we're in user-time here, so milliseconds likely aren't too important.

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);
    console.log("ElementFromPoint: " + $(elem).attr('class'));
    var startNode = (elem.childNodes.length>0?elem.childNodes[0]:elem);

    var lines = getLineCount(startNode, startRange);
    console.log("Lines: " + lines);

    var startCharIndexCharacter = 0;
    startRange.setStart(startNode, 0);
    startRange.setEnd(startNode, 1);
    var letterCount = startNode.length;
    var rangeRect = startRange.getBoundingClientRect();
    var rangeWidth = 0

    if (lines>1) {
        while ((rangeRect.bottom < y) && (startCharIndexCharacter < (letterCount-1))) {
            startCharIndexCharacter++;
            startRange.setStart(startNode, startCharIndexCharacter);
            startRange.setEnd(startNode, startCharIndexCharacter + 1);
            rangeRect = startRange.getBoundingClientRect();
            rangeWidth = rangeRect.right - rangeRect.left
        }
    }
    while (rangeRect.left < (x-(rangeWidth/2)) && (startCharIndexCharacter < (letterCount))) {
        startCharIndexCharacter++;
        startRange.setStart(startNode, startCharIndexCharacter);
        startRange.setEnd(startNode, startCharIndexCharacter + ((startCharIndexCharacter<letterCount) ? 1 : 0));
        rangeRect = startRange.getBoundingClientRect();
        rangeWidth = rangeRect.right - rangeRect.left
    }

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