Friday, November 10, 2006

Textarea Cursor Position in Internet Explorer

Background...
I'm building a content management type website and wanted to include a nice WYSIWYG editor. Unfortunately all the editors I tested are either too unpredictable and erratic, cost a fortune, or are not customizable enough for my needs (out of the box anyways).

In the end I just didn't want to deal with the awful (x)html that they produce. I also didn't want to deal with my end users, whom have never heard of HTML and won't want to "View Source" to fix the HTML when the WYSIWYG fails.

So, I decided to build my own. After a smidgen of research and testing I decided not to build my own. Now I'm going for a Wiki style code editor with a toolbar. With this I can generate clean consistent simple markup. It's not WYSIWYG but at least it will be consistent and predictable.

I quickly found that I'd need to be able to retrieve and adjust the current text selection in the textarea control. So, after not finding any satisfactory solutions via Google I've come up with my own.

Textarea Cursor Position in IE

First off I'd like to give credit to the sites that provided good tips, suggestions, and solutions.
  • parentNode - The best written and closest solution I found.
  • Mishoo - A good idea but the comments were more helpful
  • Scott Van Vliet - Also a very interesting approach. The comments were also helpful
  • Thanks to WikkaWiki which has a good solution for their text editor.
  • MSDN Library
Summary of the Implementation

My solution works like this:
  1. Make 2 TextRanges.
    1. The first TextRange selects from the beginning of the textarea to the beginning of the selected text.
    2. The second TextRange is a duplicate of the selection.
  2. Restore missing new line characters in both TextRanges
    1. The TextRange.text property right trims all the \r\n characters from the selection. We must restore the new lines to calculate the correct cursor position.
  3. Calculate the start position which is the untrimmed length of the 1st TextRange.text property.
  4. Calculate the end position which is the untrimmed length of the 2nd TextRange.text property plus the start position.
  5. Calculate the complete untrimmed selection using textarea.value.substring(startPoint, endPoint).
Further Explanation and Repetition

Internet Explorer makes this whole process very difficult. The biggest issues are:
  • It treats \r\n as one character when working with the TextRanges
  • It trims \r\n from the end of the TextRange.text so you don't know the real selection length.
My solution discovers the correct index of the start and end positions of the selection which can't be done by just using the TextRange.text.length. Once you have the positions you can use textarea.value.substring(start,end) to get the actual selected text rather than the trimmed TextRange.text property.

All the string methods (substring, indexOf, length, etc.) treat \r\n as 2 characters, however, the TextRange methods (moveStart, moveEnd, etc.) treat \r\n as 1 character. So just moving the TextRange until we reach the beginning and counting how many times we moved will be off by exactly the number of new lines passed along the way.

A Word About Changing the Selection

The TextRange.text \r\n trim issue also causes problems when attempting to change the selection's position. You need to count the newlines between the beginning and the target index and then subtract count from the target index. Then you can use this adjusted index with the TextRange.moveStart and TextRange.moveEnd methods to get the correct selection.

The Code

In my code I use 3 TextRanges (instead of 2 as described above) so that I can re-assemble the entire string and compare it to the original textarea.value. I do this to make sure I performed the untrimming correctly.

You will also notice that I only use a single do..while loop to untrim all the TextRanges. I could have done this with 3 separate loops. Even better, I probably should move the untrim bit to a separate function. I purposely made these design decisions but it could be done differently.

Also note that this bit of code was only tested in IE 6.0. Please don't complain because it doesn't work in Opera, Firefox, Safari or Lynx.


var textarea = document.getElementById("myTextArea");
textarea.focus();
var selection_range = document.selection.createRange().duplicate();

if (selection_range.parentElement() == textarea) {    // Check that the selection is actually in our textarea
  // Create three ranges, one containing all the text before the selection,
  // one containing all the text in the selection (this already exists), and one containing all
  // the text after the selection.
  var before_range = document.body.createTextRange();
  before_range.moveToElementText(textarea);                    // Selects all the text
  before_range.setEndPoint("EndToStart", selection_range);     // Moves the end where we need it

  var after_range = document.body.createTextRange();
  after_range.moveToElementText(textarea);                     // Selects all the text
  after_range.setEndPoint("StartToEnd", selection_range);      // Moves the start where we need it

  var before_finished = false, selection_finished = false, after_finished = false;
  var before_text, untrimmed_before_text, selection_text, untrimmed_selection_text, after_text, untrimmed_after_text;

  // Load the text values we need to compare
  before_text = untrimmed_before_text = before_range.text;
  selection_text = untrimmed_selection_text = selection_range.text;
  after_text = untrimmed_after_text = after_range.text;

  // Check each range for trimmed newlines by shrinking the range by 1 character and seeing
  // if the text property has changed.  If it has not changed then we know that IE has trimmed
  // a \r\n from the end.
  do {
    if (!before_finished) {
      if (before_range.compareEndPoints("StartToEnd", before_range) == 0) {
        before_finished = true;
      } else {
        before_range.moveEnd("character", -1)
        if (before_range.text == before_text) {
          untrimmed_before_text += "\r\n";
        } else {
          before_finished = true;
        }
      }
    }
    if (!selection_finished) {
      if (selection_range.compareEndPoints("StartToEnd", selection_range) == 0) {
        selection_finished = true;
      } else {
        selection_range.moveEnd("character", -1)
        if (selection_range.text == selection_text) {
          untrimmed_selection_text += "\r\n";
        } else {
          selection_finished = true;
        }
      }
    }
    if (!after_finished) {
      if (after_range.compareEndPoints("StartToEnd", after_range) == 0) {
        after_finished = true;
      } else {
        after_range.moveEnd("character", -1)
        if (after_range.text == after_text) {
          untrimmed_after_text += "\r\n";
        } else {
          after_finished = true;
        }
      }
    }

  } while ((!before_finished || !selection_finished || !after_finished));

  // Untrimmed success test to make sure our results match what is actually in the textarea
  // This can be removed once you're confident it's working correctly
  var untrimmed_text = untrimmed_before_text + untrimmed_selection_text + untrimmed_after_text;
  var untrimmed_successful = false;
  if (textarea.value == untrimmed_text) {
    untrimmed_successful = true;
  }
  // ** END Untrimmed success test

  var startPoint = untrimmed_before_text.length;
  var endPoint = startPoint + untrimmed_selection_text.length;
  var selected_text = untrimmed_selection_text;

  alert("Start Index: " + startPoint + "\nEnd Index: " + endPoint + "\nSelected Text\n'" + selected_text + "'");
}

17 comments:

  1. very good script with a direct approach to the problem and the solution. congratz dude

    ReplyDelete
  2. Anonymous9:09 AM

    Excellent! Thank you so much. I have been through a thousand "solutions" to this and nothing worked.

    I cannot believe MS have the cheek to release such awful software that it takes 86 lines to achieve what can be done in a line with Geckos!

    Big thanks!

    ReplyDelete
  3. Anonymous10:04 AM

    Thanks A MILLION!!!!

    really helpful post

    ReplyDelete
  4. I can't copy your code from your div.
    please can you sendme the code in an simple text file?

    Thanks
    Guillermo.

    ReplyDelete
  5. This comment has been removed by the author.

    ReplyDelete
  6. Thanks for the code, now it seems to be correct, the code in your textarea appears as garbled, mixed without end-line codes.

    My profile is updated, thanks for the advise.

    Best Regards,
    Guillermo.

    ReplyDelete
  7. I'm glad you got it. The code in the posting are inside DIVs and PREs

    I just realized that I never test these posts in Internet Explorer and that a bunch are screwed up. I'll have to fix them.

    ReplyDelete
  8. Anonymous1:51 AM

    Thanks for sharing, I was looking it and found your site. Good Stuff..

    Keep it up!

    ReplyDelete
  9. Great code. You really covered everything.

    Thank you!

    ReplyDelete
  10. Anonymous8:25 PM

    I made a javascript that enables the key TAB for the tabulation (indentation) in the TEXTAREA.
    On Firefox and IE, it works well.
    Here is the link on which you can test it :

    http://baramme.free.fr/vba/post/tab-enabled-textarea

    ReplyDelete
  11. This is beautiful work Jake. I've been searching for a decent solution to this for days.

    ReplyDelete
  12. hey there,

    really nice work here. it's pretty crazy what you have to go through to get those textRanges to work.

    i don't know how similar our application is, but it sounds like this might be useful for what you've come up with, even if just as a side tool.

    it handles tabs in textareas, which allows you to code in the browser window.

    it's reasonably cross-browser solution, with no Opera support just yet, but solid functionality in IE 7.

    http://teddevito.com/demos/textarea.html

    ReplyDelete
  13. Anonymous6:49 AM

    Fantastic piece of code. I've been using this for several months now and it still hasn't grown old. ;)

    Thanks for taking the time to share it.

    ReplyDelete
  14. Rulatir8:37 AM

    I totally don't believe that this must be THAT involved. I've seen whole guestbook implementations in about that many lines of code.

    ReplyDelete
  15. Is that a commentary on my abilities or on IE's poor implementation. If you can show us something better I'll link to it.

    ReplyDelete
  16. Anonymous10:55 AM

    Here is my solution, it moves a bookmark from the document.selection textrange to an element textrange. It correctly calculates the start and end positions regardless of where the element is in the page.

    function getSelectionRange(oElm)
    {
    var $r = { text: "", start: 0, end: 0, length: 0 };
    if (oElm.setSelectionRange)
    { // W3C/Gecko
    $r.start= oElm.selectionStart;
    $r.end = oElm.selectionEnd;
    $r.text = ($r.start != $r.end) ? oElm.value.substring($r.start, $r.end): "";
    }
    else if (document.selection)
    { // IE
    if (oElm.tagName && oElm.tagName === "TEXTAREA")
    {
    var $oS = document.selection.createRange().duplicate();
    var $oR = oElm.createTextRange();
    var $sB = $oS.getBookmark();
    $oR.moveToBookmark($sB);
    }
    else
    var $oR = document.selection.createRange().duplicate();
    $r.text = $oR.text;
    for (; $oR.moveStart("character", -1) !== 0; $r.start++);
    $r.end = $r.text.length + $r.start;
    }
    $r.length = $r.text.length;
    return $r;
    }

    ReplyDelete
  17. Anonymous8:58 PM

    could you please provide code here (or working example) e.g. with textarea to see how this works, i see you have previously been asked and provided. thanks

    ReplyDelete