Javascript Contenteditable - set Cursor / Caret to index

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/

Answers:

Answer

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();
    };
}
Answer

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

Answer

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
}

Tags

Recent Questions

Top Questions

Home Tags Terms of Service Privacy Policy DMCA Contact Us Javascript

©2020 All rights reserved.