Get cursor or text position in pixels for input element
Asked Answered
B

4

20

IE allows me to create a text range in an input element, upon which I can call getBoundingClientRect() and get the position in pixels of a certain character or the cursor/caret. Is there any way of getting the position of a certain character in pixels in other browsers?

var input = $("#myInput")[0];
var pixelPosition = null;
if (input.createTextRange)
{
    var range = input.createTextRange();
    range.moveStart("character", 6);
    pixelPosition = range.getBoundingClientRect();
}
else
{
    // Is there any way to create a range on an input's value?
}

I'm using jQuery, but I doubt it will be able to address my situation. I expect a pure JavaScript solution, if any, but jQuery answers are welcome.

Badger answered 3/8, 2011 at 17:27 Comment(6)
Might be related to what your looking for: #4085812Ribaudo
I'm pretty sure the short answer is "no", but I haven't got time to elaborate or research right now.Sutphin
@TimDown I've implemented the requested behaviour. Since you've got lots of experience with ranges (due your Rangy project), could you check it?Kerrison
@RobW: Sure, I'll have a look later today.Sutphin
@RobW: There's very little range-related code: it's more about positioning and styles, which I know something about but is not really my area of expertise. It looks reasonable, but I'd test it hard in different browsers and operating systems: I think text inputs in some browsers and OSes may have varying amounts of unremovable and possibly unmeasurable padding that could throw things off. There may well be other issues relating to exactly emulating the styling of the input.Sutphin
See this question to get the cursor position in characters within a text input field.Faefaeces
B
4

I ended up creating a hidden mock input out of a span positioned absolutely and styled similarly to the input. I set the text of that span to the value of the input up to the character whose position I want to find. I insert the span before the input and get it's offset:

function getInputTextPosition(input, charOffset)
{
    var pixelPosition = null;
    if (input.createTextRange)
    {
        var range = input.createTextRange();
        range.moveStart("character", charOffset);
        pixelPosition = range.getBoundingClientRect();
    }
    else
    {
        var text = input.value.substr(0, charOffset).replace(/ $/, "\xa0");
        var sizer = $("#sizer").insertBefore(input).text(text);
        pixelPosition = sizer.offset();
        pixelPosition.left += sizer.width();
        if (!text) sizer.text("."); // for computing height. An empty span returns 0
        pixelPosition.bottom = pixelPosition.top + sizer.height();
    }
    return pixelPosition
}

The css for my sizer span:

#sizer
{
    position: absolute;
    display: inline-block;
    visibility: hidden;
    margin: 3px; /* simulate padding and border without affecting height and width */
    font-family: "segoe ui", Verdana, Arial, Sans-Serif;
    font-size: 12px;
}
Badger answered 31/10, 2011 at 19:22 Comment(2)
I've created a test case following this implementation: jsfiddle.net/WYzfm. The returned offsets are always equal, regardless of the given start offset. Also, this function requires an element, #sizer to be defined. If you want to continue developing this function, I recommend using $('<div id="sizer"></div>'), so that it's not dependent on the document where it's embedded in.Kerrison
@RobW - Why would top or bottom differ for a different character offset in the same input? I would expect only left to change, which it does: jsfiddle.net/gilly3/WYzfm/2.Badger
K
17

Demo
I have written a function which behaves as expected. A very detailed demonstration panel can be found here: Fiddle: http://jsfiddle.net/56Rep/5/
The interface in the demo is self-explanatory.

The functionality as requested in the question would be implemented in my function as follows:
    var pixelPosition = getTextBoundingRect(input, 6)

Function dependencies
Updated: The function is pure JavaScript, and not dependent on any plugin or framework!
The function assumes that the getBoundingClientRect method exist. Text ranges are used when they're supported. Otherwise, the functionality is achieved using my function logic.

Function logic
The code itself contains several comments. This part goes in a deeper detail.

  1. One temporary <div> container is created.
  2. 1 - 3 <span> elements are created. Each span holds a part of the input's value (offsets 0 to selectionStart, selectionStart to selectionEnd, selectionEnd to end of string, only the second span is meaninngful).
  3. Several significant style properties from the input element are copied to these <div> and <span> tags. Only significant style properties are copied. For example, color is not copied, because it does not affect the offsets of a character in any way.#1
  4. The <div> is positioned at the exact position of the text node (input's value). Borders and paddings are taken into account, to make sure that the temporary <div> is correctly positioned.
  5. A variable is created, which holds the return value of div.getBoundingClientRect().
  6. The temporary <div> is removed, unless parameter debug is set to true.
  7. The function returns the ClientRect object. For more information about this object, see this page. The demo also shows a list of properties: top, left, right, bottom, height and width.

#1: getBoundingClientRect() (and some minor properties) is used to determine the position of the input element. Then, the padding and border width are added, to get the real position of a text node.

Known issues
The only case of an inconsistency was encountered when getComputedStyle returned a wrong value for font-family: When a page hasn't defined a font-family property, the computedStyle returns an incorrect value (even Firebug is experiencing this issue; environment: Linux, Firefox 3.6.23, font "Sans Serif").

As visible in the demo, the positioning is sometimes slightly off (almost zero, always smaller than 1 pixel).

Technical restrictions prevents the script from getting the exact offset of a text fragment when the contents has been moved, e.g. when the first visible character in an input field does not equal the first value's character.

Code

// @author Rob W       http://stackoverflow.com/users/938089/rob-w
// @name               getTextBoundingRect
// @param input          Required HTMLElement with `value` attribute
// @param selectionStart Optional number: Start offset. Default 0
// @param selectionEnd   Optional number: End offset. Default selectionStart
// @param debug          Optional boolean. If true, the created test layer
//                         will not be removed.
function getTextBoundingRect(input, selectionStart, selectionEnd, debug) {
    // Basic parameter validation
    if(!input || !('value' in input)) return input;
    if(typeof selectionStart == "string") selectionStart = parseFloat(selectionStart);
    if(typeof selectionStart != "number" || isNaN(selectionStart)) {
        selectionStart = 0;
    }
    if(selectionStart < 0) selectionStart = 0;
    else selectionStart = Math.min(input.value.length, selectionStart);
    if(typeof selectionEnd == "string") selectionEnd = parseFloat(selectionEnd);
    if(typeof selectionEnd != "number" || isNaN(selectionEnd) || selectionEnd < selectionStart) {
        selectionEnd = selectionStart;
    }
    if (selectionEnd < 0) selectionEnd = 0;
    else selectionEnd = Math.min(input.value.length, selectionEnd);

    // If available (thus IE), use the createTextRange method
    if (typeof input.createTextRange == "function") {
        var range = input.createTextRange();
        range.collapse(true);
        range.moveStart('character', selectionStart);
        range.moveEnd('character', selectionEnd - selectionStart);
        return range.getBoundingClientRect();
    }
    // createTextRange is not supported, create a fake text range
    var offset = getInputOffset(),
        topPos = offset.top,
        leftPos = offset.left,
        width = getInputCSS('width', true),
        height = getInputCSS('height', true);

        // Styles to simulate a node in an input field
    var cssDefaultStyles = "white-space:pre;padding:0;margin:0;",
        listOfModifiers = ['direction', 'font-family', 'font-size', 'font-size-adjust', 'font-variant', 'font-weight', 'font-style', 'letter-spacing', 'line-height', 'text-align', 'text-indent', 'text-transform', 'word-wrap', 'word-spacing'];

    topPos += getInputCSS('padding-top', true);
    topPos += getInputCSS('border-top-width', true);
    leftPos += getInputCSS('padding-left', true);
    leftPos += getInputCSS('border-left-width', true);
    leftPos += 1; //Seems to be necessary

    for (var i=0; i<listOfModifiers.length; i++) {
        var property = listOfModifiers[i];
        cssDefaultStyles += property + ':' + getInputCSS(property) +';';
    }
    // End of CSS variable checks

    var text = input.value,
        textLen = text.length,
        fakeClone = document.createElement("div");
    if(selectionStart > 0) appendPart(0, selectionStart);
    var fakeRange = appendPart(selectionStart, selectionEnd);
    if(textLen > selectionEnd) appendPart(selectionEnd, textLen);

    // Styles to inherit the font styles of the element
    fakeClone.style.cssText = cssDefaultStyles;

    // Styles to position the text node at the desired position
    fakeClone.style.position = "absolute";
    fakeClone.style.top = topPos + "px";
    fakeClone.style.left = leftPos + "px";
    fakeClone.style.width = width + "px";
    fakeClone.style.height = height + "px";
    document.body.appendChild(fakeClone);
    var returnValue = fakeRange.getBoundingClientRect(); //Get rect

    if (!debug) fakeClone.parentNode.removeChild(fakeClone); //Remove temp
    return returnValue;

    // Local functions for readability of the previous code
    function appendPart(start, end){
        var span = document.createElement("span");
        span.style.cssText = cssDefaultStyles; //Force styles to prevent unexpected results
        span.textContent = text.substring(start, end);
        fakeClone.appendChild(span);
        return span;
    }
    // Computing offset position
    function getInputOffset(){
        var body = document.body,
            win = document.defaultView,
            docElem = document.documentElement,
            box = document.createElement('div');
        box.style.paddingLeft = box.style.width = "1px";
        body.appendChild(box);
        var isBoxModel = box.offsetWidth == 2;
        body.removeChild(box);
        box = input.getBoundingClientRect();
        var clientTop  = docElem.clientTop  || body.clientTop  || 0,
            clientLeft = docElem.clientLeft || body.clientLeft || 0,
            scrollTop  = win.pageYOffset || isBoxModel && docElem.scrollTop  || body.scrollTop,
            scrollLeft = win.pageXOffset || isBoxModel && docElem.scrollLeft || body.scrollLeft;
        return {
            top : box.top  + scrollTop  - clientTop,
            left: box.left + scrollLeft - clientLeft};
    }
    function getInputCSS(prop, isnumber){
        var val = document.defaultView.getComputedStyle(input, null).getPropertyValue(prop);
        return isnumber ? parseFloat(val) : val;
    }
}
Kerrison answered 30/10, 2011 at 23:40 Comment(10)
+1. This is a very robust solution suitable for packaging as a plugin or even including in a distributed library. But, way more than I need. Personally, I prefer to do much of what you are doing in the CSS. It greatly reduces complexity. Also, I'm happy to assume the caller will pass good arguments rather than doing all that validation. Let the caller suffer the consequences of passing in garbage. One more thought: Rather than baking into the function the ability to get start and end positions, you could simplify the function and get both positions by just calling the function twice.Badger
@Badger I have included the end offset for completeness. The left edge offset can be obtained through .left, and the right edge offset can be calculated using .left + .width. For performance reasons, it's better to call the function, and use .left + .width, rather than calling the function twice. Supporting selectionStart and selectionEnd is not a big deal. About CSS: CSS is mainly used to copy the offset-affecting properties. If anything is unclear about my Function Logic section, please point it out, so that I can improve it.Kerrison
For the simplifications I was referring to, see the answer I posted - this was what I ended up using. By the way, your code does not work in IE. To fix it, you need to first collapse your range and then move the end by the difference of end - start. See this update: jsfiddle.net/gilly3/56Rep/4Badger
whatabout <textarea>, where last line can be shorter, than previous lines? and cursor can be placed at the end of line or at the beginning, which related to one "selectedStart"Tabby
@Tabby The method is written for <input> elements. It's not reliable for textareas. I guess that it's possible to implement it for textareas.Kerrison
@rob-w, you can use input.scrollLeft/input.scrollTop in IE and ChromeTabby
@Tabby Getting the scrolling position is not difficult. Mimicking a textarea is. Coincidentally, someone asked how to style a textarea/div in exactely the same way - see stackoverflow.com/q/11569050?how-to-make-textarea-like-as-div. The (yet to come) answer to that question will offer keys to achieve your desired result.Kerrison
@Rob-w, Getting the scrolling position is not cross browser... and i don't know how to do it in FF and Opera; also there are problems with caret before/after line breaksTabby
Very nice! I added a bit of code to get around the newline and wrapping issues on textarea elements. Here's the gist. The changes are lines 40-41 (text wrapping) and 81-87 (newline issue--it gives position off by a space, but this could be negated later if necessary).Sitton
I've just updated the incredibly lightweight and robust textarea-caret-position Component library, to support <input type="text"> as well. Demo at jsfiddle.net/dandv/aFPA7Faefaeces
K
5

2016 update: A more modern HTML5 based solution would be to use the contenteditable property.

<div contenteditable="true">  <!-- behaves as input -->
   Block of regular text, and <span id='interest'>text of interest</span>
</div>

We can now find the position of the span using jquery offset(). And of course, the <span> tags can be inserted upfront or dynamically.

Katabatic answered 16/12, 2016 at 20:30 Comment(0)
B
4

I ended up creating a hidden mock input out of a span positioned absolutely and styled similarly to the input. I set the text of that span to the value of the input up to the character whose position I want to find. I insert the span before the input and get it's offset:

function getInputTextPosition(input, charOffset)
{
    var pixelPosition = null;
    if (input.createTextRange)
    {
        var range = input.createTextRange();
        range.moveStart("character", charOffset);
        pixelPosition = range.getBoundingClientRect();
    }
    else
    {
        var text = input.value.substr(0, charOffset).replace(/ $/, "\xa0");
        var sizer = $("#sizer").insertBefore(input).text(text);
        pixelPosition = sizer.offset();
        pixelPosition.left += sizer.width();
        if (!text) sizer.text("."); // for computing height. An empty span returns 0
        pixelPosition.bottom = pixelPosition.top + sizer.height();
    }
    return pixelPosition
}

The css for my sizer span:

#sizer
{
    position: absolute;
    display: inline-block;
    visibility: hidden;
    margin: 3px; /* simulate padding and border without affecting height and width */
    font-family: "segoe ui", Verdana, Arial, Sans-Serif;
    font-size: 12px;
}
Badger answered 31/10, 2011 at 19:22 Comment(2)
I've created a test case following this implementation: jsfiddle.net/WYzfm. The returned offsets are always equal, regardless of the given start offset. Also, this function requires an element, #sizer to be defined. If you want to continue developing this function, I recommend using $('<div id="sizer"></div>'), so that it's not dependent on the document where it's embedded in.Kerrison
@RobW - Why would top or bottom differ for a different character offset in the same input? I would expect only left to change, which it does: jsfiddle.net/gilly3/WYzfm/2.Badger
F
3

May 2014 update: The incredibly lightweight and robust textarea-caret-position Component library now supports <input type="text"> as well, rendering all other answers obsolete.

A demo is available at http://jsfiddle.net/dandv/aFPA7/

Thanks to Rob W for inspiration towards RTL support.

Faefaeces answered 2/5, 2014 at 12:18 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.