8

How would I go a about modifying this(How to set caret(cursor) position in contenteditable element (div)?) so it accepts a number index and element and sets the cursor position to that index?

For example: If I had the paragraph:

<p contenteditable="true">This is a paragraph.</p>

And I called:

setCaret($(this).get(0), 3)

The cursor would move to index 3 like so:

Thi|s is a paragraph.

I have this but with no luck:

function setCaret(contentEditableElement, index)
{
    var range,selection;
    if(document.createRange)//Firefox, Chrome, Opera, Safari, IE 9+
    {
        range = document.createRange();//Create a range (a range is a like the selection but invisible)
        range.setStart(contentEditableElement,index);
        range.collapse(true);
        selection = window.getSelection();//get the selection object (allows you to change selection)
        selection.removeAllRanges();//remove any selections already made
        selection.addRange(range);//make the range you have just created the visible selection
    }
    else if(document.selection)//IE 8 and lower
    { 
        range = document.body.createTextRange();//Create a range (a range is a like the selection but invisible)
        range.moveToElementText(contentEditableElement);//Select the entire contents of the element with the range
        range.collapse(false);//collapse the range to the end point. false means collapse to end rather than the start
        range.select();//Select the range (make it the visible selection
    }
}

http://jsfiddle.net/BanQU/4/

Community
  • 1
  • 1
Ryan King
  • 3,538
  • 12
  • 48
  • 72

3 Answers3

12

Here's an answer adapted from Persisting the changes of range objects after selection in HTML. Bear in mind that this is less than perfect in several ways (as is MaxArt's, which uses the same approach): firstly, only text nodes are taken into account, meaning that line breaks implied by <br> and block elements are not included in the index; secondly, all text nodes are considered, even those inside elements that are hidden by CSS or inside <script> elements; thirdly, consecutive white space characters that are collapsed on the page are all included in the index; finally, IE <= 8's rules are different again because it uses a different mechanism.

var setSelectionByCharacterOffsets = null;

if (window.getSelection && document.createRange) {
    setSelectionByCharacterOffsets = function(containerEl, start, end) {
        var charIndex = 0, range = document.createRange();
        range.setStart(containerEl, 0);
        range.collapse(true);
        var nodeStack = [containerEl], node, foundStart = false, stop = false;

        while (!stop && (node = nodeStack.pop())) {
            if (node.nodeType == 3) {
                var nextCharIndex = charIndex + node.length;
                if (!foundStart && start >= charIndex && start <= nextCharIndex) {
                    range.setStart(node, start - charIndex);
                    foundStart = true;
                }
                if (foundStart && end >= charIndex && end <= nextCharIndex) {
                    range.setEnd(node, end - charIndex);
                    stop = true;
                }
                charIndex = nextCharIndex;
            } else {
                var i = node.childNodes.length;
                while (i--) {
                    nodeStack.push(node.childNodes[i]);
                }
            }
        }

        var sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);
    }
} else if (document.selection) {
    setSelectionByCharacterOffsets = function(containerEl, start, end) {
        var textRange = document.body.createTextRange();
        textRange.moveToElementText(containerEl);
        textRange.collapse(true);
        textRange.moveEnd("character", end);
        textRange.moveStart("character", start);
        textRange.select();
    };
}
Community
  • 1
  • 1
Tim Down
  • 318,141
  • 75
  • 454
  • 536
  • I see you've implemented an iterative tree traversal routine. But AFAIK those browsers that support `getSelection` support `document.createTreeWalker` too, [which is faster](http://stackoverflow.com/questions/2579666/getelementsbytagname-equivalent-for-textnodes). So we should go for rit. – MaxArt Apr 19 '13 at 09:05
  • @MaxArt: Yes, I've never come across a browser that supports Range but not TreeWalker (both being from DOM Level 2, that makes sense). I improved those tests and made a jsPerf that suggests you're right about speed, in most browsers. http://jsperf.com/text-node-traversal – Tim Down Apr 19 '13 at 10:04
  • I'm actually surprised that TreeWalker is *slower* in Chrome :| But anyway it saves a bunch of code pain... – MaxArt Apr 19 '13 at 10:14
  • When substituting the code above (http://jsfiddle.net/zQUhV/20/) with your code (http://jsfiddle.net/zQUhV/21/) it doesn't seem to work. Note: the jsfiddle code is built to traverse between the last 2 paragraphs using the arrow keys. It works in the first link but not the second, however the first link breaks when index and text length are equal, `setCaret(prev.get(0), prev.text().length)` – Ryan King Apr 25 '13 at 07:38
  • 1
    @RyanKing: You have a syntax error in the jsFiddle (`?` instead of `{`). http://jsfiddle.net/zQUhV/22/ – Tim Down Apr 25 '13 at 09:11
  • ... which it appears was in my code as well :) Thanks for the edit. – Tim Down Jun 20 '14 at 08:19
  • @TimDown, why you've used `end` variable? and how to get it? – Vedant Terkar Aug 07 '14 at 07:20
  • @VedantTerkar: `end` specifies the character offset for the end of the selection. If you want to place the caret and have no selection, use the same value as `start`. – Tim Down Aug 07 '14 at 08:18
  • @TimDown awesome solution. How would you go on about to also take into account line breaks ? – pelican_george May 27 '16 at 11:38
7

range.setStart and range.setEnd can be used on text nodes, not element nodes. Or else they will raise a DOM Exception. So what you have to do is

range.setStart(contentEditableElement.firstChild, index);

I don't get what you did for IE8 and lower. Where did you mean to use index?

Overall, your code fails if the content of the nodes is more than a single text node. It may happen for nodes with isContentEditable === true, since the user can paste text from Word or other places, or create a new line and so on.

Here's an adaptation of what I did in my framework:

var setSelectionRange = function(element, start, end) {
    var rng = document.createRange(),
        sel = getSelection(),
        n, o = 0,
        tw = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, null);
    while (n = tw.nextNode()) {
        o += n.nodeValue.length;
        if (o > start) {
            rng.setStart(n, n.nodeValue.length + start - o);
            start = Infinity;
        }
        if (o >= end) {
            rng.setEnd(n, n.nodeValue.length + end - o);
            break;
        }
    }
    sel.removeAllRanges();
    sel.addRange(rng);
};

var setCaret = function(element, index) {
    setSelectionRange(element, index, index);
};

The trick here is to use the setSelectionRange function - that selects a range of text inside and element - with start === end. In contentEditable elements, this puts the caret in the desired position.

This should work in all modern browsers, and for elements that have more than just a text node as a descendant. I'll let you add checks for start and end to be in the proper range.

For IE8 and lower, things are a little harder. Things would look a bit like this:

var setSelectionRange = function(element, start, end) {
    var rng = document.body.createTextRange();
    rng.moveToElementText(element);
    rng.moveStart("character", start);
    rng.moveEnd("character", end - element.innerText.length - 1);
    rng.select();
};

The problem here is that innerText is not good for this kind of things, as some white spaces are collapsed. Things are fine if there's just a text node, but are screwed for something more complicated like the ones you get in contentEditable elements.

IE8 doesn't support textContent, so you have to count the characters using a TreeWalker. But than again IE8 doesn't support TreeWalker either, so you have to walk the DOM tree all by yourself...

I still have to fix this, but somehow I doubt I'll ever will. Even if I did code a polyfill for TreeWalker in IE8 and lower...

MaxArt
  • 22,200
  • 10
  • 82
  • 81
  • Thanks, I should've mentioned I never got round to the IE8 and lower code. And I never considered people pasting text into the element - I'll have to look into that. – Ryan King Apr 19 '13 at 03:10
  • 1
    `setStart()` and `setEnd()` range methods definitely can be used with elements, but the offset represents the number of child nodes of the element prior to the boundary rather than a character index. – Tim Down Apr 19 '13 at 08:30
  • @TimDown Yes, but in Ryan's case it throws an exception, because the second argument is 3 (5 in the fiddle). Thanks for pointing it out, though, it wasn't clear. And I didn't use `collapse` because the function is `setSeletionRange`, which is then called by `setCaret` but it generally creates non-collapsed selections. – MaxArt Apr 19 '13 at 08:47
  • In IE <= 8, why not use TextRange's `moveEnd()` method first? `rng.moveEnd("character", end); rng.moveStart("character", start);` – Tim Down Apr 19 '13 at 08:47
  • @MaxArt: I realised my mistake about `collapse()` and deleted that comment. – Tim Down Apr 19 '13 at 08:48
  • @TimDown IIRC (and I admit my memory is a bit blurry about it) `moveEnd` doesn't work as one expects (as usual from Microsoft's proprietary extensions). It counts the index from the *end* of the text, so you have to give a negative value if you want the selection to end before. I.e. `moveEnd("character", 0)` sets the selection end to the end of the text content of the element. – MaxArt Apr 19 '13 at 08:51
  • @MaxArt: It works just the same as `moveStart()`. If you collapse the TextRange first (which I failed to mention), all is well. – Tim Down Apr 19 '13 at 10:06
  • @TimDown Ah, nice to know this. Anyway, this will be forgotten as soon as IE8 is history (not soon enough, though). – MaxArt Apr 19 '13 at 10:16
  • This breaks when index and text length are equal `setCaret(prev.get(0), prev.text().length)` – Ryan King Apr 25 '13 at 07:20
  • why you have used `end` variable in that? and how to get it? – Vedant Terkar Aug 07 '14 at 07:15
  • The solution seems to be solid but fails when the index is just before (or after) a `
    ` tag. I know this is old, but has anyone been able to deal with that?
    – keligijus May 04 '18 at 06:04
  • @keligijus It *shouldn't* fail - although it's been a while since last time I used it. The only edge case that comes in my mind is when the `contentEditable` element contains *just* a `
    ` and no text nodes. Can you create a fiddle?
    – MaxArt May 05 '18 at 21:16
  • Hey @MaxArt, thanks for your answer. Here's a fiddle https://jsfiddle.net/q3bhctwv/ It seems that undesired behaviour occurs whenever the cursor is at the end of line. – keligijus May 07 '18 at 12:12
  • 1
    @keligijus Ah, pesky little bug... It looks like checking `o >= start` does the trick, but still, if the cursor is at the beginning of a new line, it's brought back to the end of the previous line. That's because, counting text-wise, it's the "same" position... Have fun with some edge cases. :| – MaxArt May 08 '18 at 12:53
1

Here is my improvement over Tim's answer. It removes the caveat about hidden characters, but the other caveats remain:

  • only text nodes are taken into account (line breaks implied by <br> and block elements are not included in the index)
  • all text nodes are considered, even those inside elements that are hidden by CSS or inside elements
  • IE <= 8's rules are different again because it uses a different mechanism.

The code:

var setSelectionByCharacterOffsets = null;

if (window.getSelection && document.createRange) {
    setSelectionByCharacterOffsets = function(containerEl, start, end) {
        var charIndex = 0, range = document.createRange();
        range.setStart(containerEl, 0);
        range.collapse(true);
        var nodeStack = [containerEl], node, foundStart = false, stop = false;

        while (!stop && (node = nodeStack.pop())) {
            if (node.nodeType == 3) {
                var hiddenCharacters = findHiddenCharacters(node, node.length)
                var nextCharIndex = charIndex + node.length - hiddenCharacters;

                if (!foundStart && start >= charIndex && start <= nextCharIndex) {
                    var nodeIndex = start-charIndex
                    var hiddenCharactersBeforeStart = findHiddenCharacters(node, nodeIndex)
                    range.setStart(node, nodeIndex + hiddenCharactersBeforeStart);
                    foundStart = true;
                }
                if (foundStart && end >= charIndex && end <= nextCharIndex) {
                    var nodeIndex = end-charIndex
                    var hiddenCharactersBeforeEnd = findHiddenCharacters(node, nodeIndex)
                    range.setEnd(node, nodeIndex + hiddenCharactersBeforeEnd);
                    stop = true;
                }
                charIndex = nextCharIndex;
            } else {
                var i = node.childNodes.length;
                while (i--) {
                    nodeStack.push(node.childNodes[i]);
                }
            }
        }

        var sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);
    }
} else if (document.selection) {
    setSelectionByCharacterOffsets = function(containerEl, start, end) {
        var textRange = document.body.createTextRange();
        textRange.moveToElementText(containerEl);
        textRange.collapse(true);
        textRange.moveEnd("character", end);
        textRange.moveStart("character", start);
        textRange.select();
    };
}

var x = document.getElementById('a')
x.focus()
setSelectionByCharacterOffsets(x, 1, 13)

function findHiddenCharacters(node, beforeCaretIndex) {
    var hiddenCharacters = 0
    var lastCharWasWhiteSpace=true
    for(var n=0; n-hiddenCharacters<beforeCaretIndex &&n<node.length; n++) {
        if([' ','\n','\t','\r'].indexOf(node.textContent[n]) !== -1) {
            if(lastCharWasWhiteSpace)
                hiddenCharacters++
            else
                lastCharWasWhiteSpace = true
        } else {
            lastCharWasWhiteSpace = false   
        }
    }

    return hiddenCharacters
}
B T
  • 57,525
  • 34
  • 189
  • 207