views:

180

answers:

3

I'm trying to extract the exact selection and cursor location from a textarea. As usual, what's easy in most browsers is not in IE.

I'm using this:

var sel=document.selection.createRange();
var temp=sel.duplicate();
temp.moveToElementText(textarea);
temp.setEndPoint("EndToEnd", sel);
selectionEnd = temp.text.length;
selectionStart = selectionEnd - sel.text.length;

Which works 99% of the time. The problem is that TextRange.text doesn't return leading or trailing new line characters. So when the cursor is a couple of blank lines after a paragraph it yields a position at the end of the preceeding paragraph - rather than the actual cursor position.

eg:

the quick brown fox|    <- above code thinks the cursor is here

|    <- when really it's here

The only fix I can think of is to temporarily insert a character before and after the selection, grab the actual selection and then remove those temp characters again. It's a hack but in a quick experiment looks like it will work.

But first I'd like to be sure there's not an easier way.

+1  A: 

I've come across this problem and written the following that works in all cases. In IE it does use the method you suggested of temporarily inserting a character at the selection boundary, and then uses document.execCommand("undo") to remove the inserted character and prevent the insertion from remaining on the undo stack. I'm pretty sure there's no easier way. Happily, IE 9 will support the selectionStart and selectionEnd properties.

function getSelectionBoundary(el, isStart) {
    var property = isStart ? "selectionStart" : "selectionEnd";
    var originalValue, textInputRange, precedingRange, pos, bookmark;

    if (typeof el[property] == "number") {
        return el[property];
    } else if (document.selection && document.selection.createRange) {
        el.focus();
        var range = document.selection.createRange();

        if (range) {
            range.collapse(!!isStart);

            originalValue = el.value;
            textInputRange = el.createTextRange();
            precedingRange = textInputRange.duplicate();
            pos = 0;

            if (originalValue.indexOf("\r\n") > -1) {
                // Trickier case where input value contains line breaks

                // Insert a character in the text input range and use that as
                // a marker
                range.text = " ";
                bookmark = range.getBookmark();
                textInputRange.moveToBookmark(bookmark);
                precedingRange.setEndPoint("EndToStart", textInputRange);
                pos = precedingRange.text.length - 1;

                // Executing an undo command to delete the character inserted
                // prevents this method adding to the undo stack. This trick
                // came from a user called Trenda on MSDN:
                // http://msdn.microsoft.com/en-us/library/ms534676%28VS.85%29.aspx
                document.execCommand("undo");
            } else {
                // Easier case where input value contains no line breaks
                bookmark = range.getBookmark();
                textInputRange.moveToBookmark(bookmark);
                precedingRange.setEndPoint("EndToStart", textInputRange);
                pos = precedingRange.text.length;
            }
            return pos;
        }
    }
    return 0;
}

var el = document.getElementById("your_textarea");
var startPos = getSelectionBoundary(el, true);
var endPos = getSelectionBoundary(el, false);
alert(startPos + ", " + endPos);

UPDATE

Based on bobince's suggested approach in the comments, I've created the following, which seems to work well. Some notes:

  1. bobince's approach is simpler and shorter.
  2. My approach is intrusive: it makes changes to the input's value before reverting those changes, although there is no visible effect of this.
  3. My approach has the advantage of keeping all operations within the input. bobince's approach relies on creating ranges that span from the start of the body to the current selection.
  4. A consequence of 3. is that the performance of bobince's varies with the position of the input within the document whereas mine does not. My simple tests suggest that when the input is close to the start of the document, bobince's approach is significantly faster. When the input is after a significant chunk of HTML, my approach is faster.

function getSelection(el) {
    var start = 0, end = 0, normalizedValue, textInputRange, elStart;
    var range = document.selection.createRange();
    var bigNum = -1e8;

    if (range && range.parentElement() == el) {
        normalizedValue = el.value.replace(/\r\n/g, "\n");

        start = -range.moveStart("character", bigNum);
        end = -range.moveEnd("character", bigNum);

        textInputRange = el.createTextRange();
        range.moveToBookmark(textInputRange.getBookmark());
        elStart = range.moveStart("character", bigNum);

        // Adjust the position to be relative to the start of the input
        start += elStart;
        end += elStart;

        // Correct for line breaks so that offsets are relative to the
        // actual value of the input
        start += normalizedValue.slice(0, start).split("\n").length - 1;
        end += normalizedValue.slice(0, end).split("\n").length - 1;
    }
    return {
        start: start,
        end: end
    };
}

var el = document.getElementById("your_textarea");
var sel = getSelection(el);
alert(sel.start + ", " + sel.end);
Tim Down
This seems a very messy workaround! Is there any reason to prefer it to just using the `range.moveStart('character', -10000000)` method of determining selection boundaries in a textarea? That still has `\r\n` to fix up, but that's relatively simple in comparison.
bobince
bobince: erm. I can't remember the problem I thought existed with your suggestion and now can't find one. I'll get back to you.
Tim Down
bobince: hmm. Looks like you're right. I was sure there was some kind of problem with empty lines with your approach but there doesn't seem to be.
Tim Down
bobince: I've looked into this a bit further. My conclusions are now in my answer, but to answer your original question, the one reason I can see to favour my approach is that mine is independent of the position of the input within the document, whereas yours is not. If your use your approach on an input at the end of a large HTML document, performance is compromised.
Tim Down
Interesting, there's a measurable performance difference at the end of the document even though the `moveStart` method doesn't actually walk over any of those characters? That's highly strange. (Oh, IE...)
bobince
If `range` is obtained directly from the selection, `range.moveStart('character', -10000000)` will move the start of the range to the start of the body, which you must know since you're correcting for it in the example of yours I found here: http://stackoverflow.com/questions/1738808/keypress-in-jquery-press-tab-inside-textarea-when-editing-an-existing-text/1739088#1739088 Perhaps I've misunderstood your point?
Tim Down
This is really relevant to what I'm doing right now, and it works, but for some reason it takes ~500ms to execute each time *sigh*. +1 for the time put in, anyway.
Andy E
+1  A: 

The move by negative bazillion seems to work perfectly.

Here's what I ended up with:

var sel=document.selection.createRange();
var temp=sel.duplicate();
temp.moveToElementText(textarea);
var basepos=-temp.moveStart('character', -10000000);

this.m_selectionStart = -sel.moveStart('character', -10000000)-basepos;
this.m_selectionEnd = -sel.moveEnd('character', -10000000)-basepos;
this.m_text=textarea.value.replace(/\r\n/gm,"\n");

Thanks bobince - how can I vote up your answer when it's just a comment :(

cantabilesoftware
I've looked into this a bit further. See my answer for my conclusions. Two points about what you have there: it will throw an error if you use it on an input instead of a textarea, and also the positions it returns are relative to a piece of text that isn't the actual value in the input: I think the selection position is conceptually an offset within the input's value, which contains `\r\n` for each line break rather than `\n`.
Tim Down
Yes, that's why the function mentioned at http://stackoverflow.com/questions/1738808#1739088 returns the actual strings, corrected for `\r\n`, rather than indices into the value; I guess the same will happen here with `m_text`.
bobince
Yep. Your example doesn't return the positions though.
Tim Down
I've posted a new answer that improves on this by removing the need to move the start of the TextRange to the start of the document's body.
Tim Down
+2  A: 

I'm adding another answer since my previous one is already getting somewhat epic.

This is what I consider the best version yet: it takes bobince's approach (mentioned in the comments to my first answer) and fixes the two things I didn't like about it, which were first that it relies on TextRanges that stray outside the textarea (thus harming performance), and second the dirtiness of having to pick a giant number for the number of characters to move the range boundary.

function getSelection(el) {
    var start = 0, end = 0, normalizedValue, range,
        textInputRange, len, endRange;

    if (typeof el.selectionStart == "number" && typeof el.selectionEnd == "number") {
        start = el.selectionStart;
        end = el.selectionEnd;
    } else {
        range = document.selection.createRange();

        if (range && range.parentElement() == el) {
            len = el.value.length;
            normalizedValue = el.value.replace(/\r\n/g, "\n");

            // Create a working TextRange that lives only in the input
            textInputRange = el.createTextRange();
            textInputRange.moveToBookmark(range.getBookmark());

            // Check if the start and end of the selection are at the very end
            // of the input, since moveStart/moveEnd doesn't return what we want
            // in those cases
            endRange = el.createTextRange();
            endRange.collapse(false);

            if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) {
                start = end = len;
            } else {
                start = -textInputRange.moveStart("character", -len);
                start += normalizedValue.slice(0, start).split("\n").length - 1;

                if (textInputRange.compareEndPoints("EndToEnd", endRange) > -1) {
                    end = len;
                } else {
                    end = -textInputRange.moveEnd("character", -len);
                    end += normalizedValue.slice(0, end).split("\n").length - 1;
                }
            }
        }
    }

    return {
        start: start,
        end: end
    };
}

var el = document.getElementById("your_textarea");
var sel = getSelection(el);
alert(sel.start + ", " + sel.end);
Tim Down
Nice. Like the idea of using the text length instead of really big number on the moveStart/moveEnd.
cantabilesoftware
Ah, I didn't see this one. Result is 20-30ms now, excellent work!
Andy E
@Andy: I've written a jQuery plug-in that includes this. Not yet documented and lumped in with a tenuously related project, but working: http://code.google.com/p/rangy/downloads/detail?name=textinputs_jquery-src.js
Tim Down
@Tim: ah, so you're the creator of rangy. The project I used your answer in is an IE only project and I'm not working with jQuery, but I'll definitely find use for your plug-in at some point, I'm sure.
Andy E