Set cursor position on contentEditable <div>

This solution works in all major browsers:

saveSelection() is attached to the onmouseup and onkeyup events of the div and saves the selection to the variable savedRange.

restoreSelection() is attached to the onfocus event of the div and reselects the selection saved in savedRange.

This works perfectly unless you want the selection to be restored when the user clicks the div aswell (which is a bit unintuitative as normally you expect the cursor to go where you click but code included for completeness)

To achieve this the onclick and onmousedown events are canceled by the function cancelEvent() which is a cross browser function to cancel the event. The cancelEvent() function also runs the restoreSelection() function because as the click event is cancelled the div doesn't receive focus and therefore nothing is selected at all unless this functions is run.

The variable isInFocus stores whether it is in focus and is changed to "false" onblur and "true" onfocus. This allows click events to be cancelled only if the div is not in focus (otherwise you would not be able to change the selection at all).

If you wish to the selection to be change when the div is focused by a click, and not restore the selection onclick (and only when focus is given to the element programtically using document.getElementById("area").focus(); or similar then simply remove the onclick and onmousedown events. The onblur event and the onDivBlur() and cancelEvent() functions can also safely be removed in these circumstances.

This code should work if dropped directly into the body of an html page if you want to test it quickly:

<div id="area" style="width:300px;height:300px;" onblur="onDivBlur();" onmousedown="return cancelEvent(event);" onclick="return cancelEvent(event);" contentEditable="true" onmouseup="saveSelection();" onkeyup="saveSelection();" onfocus="restoreSelection();"></div>
<script type="text/javascript">
var savedRange,isInFocus;
function saveSelection()
{
    if(window.getSelection)//non IE Browsers
    {
        savedRange = window.getSelection().getRangeAt(0);
    }
    else if(document.selection)//IE
    { 
        savedRange = document.selection.createRange();  
    } 
}

function restoreSelection()
{
    isInFocus = true;
    document.getElementById("area").focus();
    if (savedRange != null) {
        if (window.getSelection)//non IE and there is already a selection
        {
            var s = window.getSelection();
            if (s.rangeCount > 0) 
                s.removeAllRanges();
            s.addRange(savedRange);
        }
        else if (document.createRange)//non IE and no selection
        {
            window.getSelection().addRange(savedRange);
        }
        else if (document.selection)//IE
        {
            savedRange.select();
        }
    }
}
//this part onwards is only needed if you want to restore selection onclick
var isInFocus = false;
function onDivBlur()
{
    isInFocus = false;
}

function cancelEvent(e)
{
    if (isInFocus == false && savedRange != null) {
        if (e && e.preventDefault) {
            //alert("FF");
            e.stopPropagation(); // DOM style (return false doesn't always work in FF)
            e.preventDefault();
        }
        else {
            window.event.cancelBubble = true;//IE stopPropagation
        }
        restoreSelection();
        return false; // false = IE style
    }
}
</script>

Update

I've written a cross-browser range and selection library called Rangy that incorporates an improved version of the code I posted below. You can use the selection save and restore module for this particular question, although I'd be tempted to use something like @Nico Burns's answer if you're not doing anything else with selections in your project and don't need the bulk of a library.

Previous answer

You can use IERange (http://code.google.com/p/ierange/) to convert IE's TextRange into something like a DOM Range and use it in conjunction with something like eyelidlessness's starting point. Personally I would only use the algorithms from IERange that do the Range <-> TextRange conversions rather than use the whole thing. And IE's selection object doesn't have the focusNode and anchorNode properties but you should be able to just use the Range/TextRange obtained from the selection instead.

I might put something together to do this, will post back here if and when I do.

EDIT:

I've created a demo of a script that does this. It works in everything I've tried it in so far except for a bug in Opera 9, which I haven't had time to look into yet. Browsers it works in are IE 5.5, 6 and 7, Chrome 2, Firefox 2, 3 and 3.5, and Safari 4, all on Windows.

http://www.timdown.co.uk/code/selections/

Note that selections may be made backwards in browsers so that the focus node is at the start of the selection and hitting the right or left cursor key will move the caret to a position relative to the start of the selection. I don't think it is possible to replicate this when restoring a selection, so the focus node is always at the end of the selection.

I will write this up fully at some point soon.


This is compatible with the standards-based browsers, but will probably fail in IE. I'm providing it as a starting point. IE doesn't support DOM Range.

var editable = document.getElementById('editable'),
    selection, range;

// Populates selection and range variables
var captureSelection = function(e) {
    // Don't capture selection outside editable region
    var isOrContainsAnchor = false,
        isOrContainsFocus = false,
        sel = window.getSelection(),
        parentAnchor = sel.anchorNode,
        parentFocus = sel.focusNode;

    while(parentAnchor && parentAnchor != document.documentElement) {
        if(parentAnchor == editable) {
            isOrContainsAnchor = true;
        }
        parentAnchor = parentAnchor.parentNode;
    }

    while(parentFocus && parentFocus != document.documentElement) {
        if(parentFocus == editable) {
            isOrContainsFocus = true;
        }
        parentFocus = parentFocus.parentNode;
    }

    if(!isOrContainsAnchor || !isOrContainsFocus) {
        return;
    }

    selection = window.getSelection();

    // Get range (standards)
    if(selection.getRangeAt !== undefined) {
        range = selection.getRangeAt(0);

    // Get range (Safari 2)
    } else if(
        document.createRange &&
        selection.anchorNode &&
        selection.anchorOffset &&
        selection.focusNode &&
        selection.focusOffset
    ) {
        range = document.createRange();
        range.setStart(selection.anchorNode, selection.anchorOffset);
        range.setEnd(selection.focusNode, selection.focusOffset);
    } else {
        // Failure here, not handled by the rest of the script.
        // Probably IE or some older browser
    }
};

// Recalculate selection while typing
editable.onkeyup = captureSelection;

// Recalculate selection after clicking/drag-selecting
editable.onmousedown = function(e) {
    editable.className = editable.className + ' selecting';
};
document.onmouseup = function(e) {
    if(editable.className.match(/\sselecting(\s|$)/)) {
        editable.className = editable.className.replace(/ selecting(\s|$)/, '');
        captureSelection();
    }
};

editable.onblur = function(e) {
    var cursorStart = document.createElement('span'),
        collapsed = !!range.collapsed;

    cursorStart.id = 'cursorStart';
    cursorStart.appendChild(document.createTextNode('—'));

    // Insert beginning cursor marker
    range.insertNode(cursorStart);

    // Insert end cursor marker if any text is selected
    if(!collapsed) {
        var cursorEnd = document.createElement('span');
        cursorEnd.id = 'cursorEnd';
        range.collapse();
        range.insertNode(cursorEnd);
    }
};

// Add callbacks to afterFocus to be called after cursor is replaced
// if you like, this would be useful for styling buttons and so on
var afterFocus = [];
editable.onfocus = function(e) {
    // Slight delay will avoid the initial selection
    // (at start or of contents depending on browser) being mistaken
    setTimeout(function() {
        var cursorStart = document.getElementById('cursorStart'),
            cursorEnd = document.getElementById('cursorEnd');

        // Don't do anything if user is creating a new selection
        if(editable.className.match(/\sselecting(\s|$)/)) {
            if(cursorStart) {
                cursorStart.parentNode.removeChild(cursorStart);
            }
            if(cursorEnd) {
                cursorEnd.parentNode.removeChild(cursorEnd);
            }
        } else if(cursorStart) {
            captureSelection();
            var range = document.createRange();

            if(cursorEnd) {
                range.setStartAfter(cursorStart);
                range.setEndBefore(cursorEnd);

                // Delete cursor markers
                cursorStart.parentNode.removeChild(cursorStart);
                cursorEnd.parentNode.removeChild(cursorEnd);

                // Select range
                selection.removeAllRanges();
                selection.addRange(range);
            } else {
                range.selectNode(cursorStart);

                // Select range
                selection.removeAllRanges();
                selection.addRange(range);

                // Delete cursor marker
                document.execCommand('delete', false, null);
            }
        }

        // Call callbacks here
        for(var i = 0; i < afterFocus.length; i++) {
            afterFocus[i]();
        }
        afterFocus = [];

        // Register selection again
        captureSelection();
    }, 10);
};