Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Set cursor position in content-editable div

Summary:

I am trying to achieve the effect where when user types a ( or [ in the content-editable div, the second ) or ] is auto-inserted, and the caret be positioned between the two of them, that is, between ( and ).


FIDDLE

Type to the right of the --s and see how in the first line it works while doesn't work in the second.


My effort:

I am using this code (by Tim Down) to both highlight some part of text and set cursor position. The former works but latter doesn't :(

function getTextNodesIn(node) { // helper
    var textNodes = [];
    if (node.nodeType == 3) {
        textNodes.push(node);
    } else {
        var children = node.childNodes;
        for (var i = 0, len = children.length; i < len; ++i) {
            textNodes.push.apply(textNodes, getTextNodesIn(children[i]));
        }
    }
    return textNodes;
}

function highlightText(el, start, end) { // main
    if (el.tagName === "DIV") { // content-editable div
        var range = document.createRange();
        range.selectNodeContents(el);
        var textNodes = getTextNodesIn(el);
        var foundStart = false;
        var charCount = 0,
            endCharCount;

        for (var i = 0, textNode; textNode = textNodes[i++];) {
            endCharCount = charCount + textNode.length;
            if (!foundStart && start >= charCount && (start < endCharCount || (start == endCharCount && i < textNodes.length))) {
                range.setStart(textNode, start - charCount);
                foundStart = true;
            }
            if (foundStart && end <= endCharCount) {
                range.setEnd(textNode, end - charCount);
                break;
            }
            charCount = endCharCount;
        }

        var sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);
    } else { // textarea
        el.selectionStart = start;
        el.selectionEnd = end;
    }
}

Notes:

  1. <div> will have child elements (mostly <br>s).
  2. Only Chrome support required using vanilla JS

My question:

  1. Why doesn't the above function work?
  2. How can it be made to work?

I have spent hours searching for this and found nothing much useful. Some were about setting at start or end of a child div but for me it can be any location, anywhere.

UPDATE:

Thanks to everyone is finally finished development!

like image 731
Gaurang Tandon Avatar asked Jun 20 '14 14:06

Gaurang Tandon


2 Answers

Here's a much simpler approach. There are a few things to note:

  • keypress is the only key event in which you can reliably detect which character has been typed. keyup and keydown won't do.
  • The code handles the insertion of parentheses/braces manually by preventing the default action of the keypress event.
  • The selection/caret stuff is simple when using DOM methods.
  • This won't work in IE <= 8, which have different range and selection APIs. If you need support for those browsers, I'd suggest using my own Rangy library. It is possible without it but I really don't want to write the extra code.

Demo:

http://jsfiddle.net/HPeb2/

Code:

var editableEl = document.getElementById("editable");
editableEl.addEventListener("keypress", function(e) {
    var charTyped = String.fromCharCode(e.which);
    if (charTyped == "{" || charTyped == "(") {
        // Handle this case ourselves
        e.preventDefault();

        var sel = window.getSelection();
        if (sel.rangeCount > 0) {
            // First, delete the existing selection
            var range = sel.getRangeAt(0);
            range.deleteContents();

            // Insert a text node at the caret containing the braces/parens
            var text = (charTyped == "{") ? "{}" : "()";
            var textNode = document.createTextNode(text);
            range.insertNode(textNode);

            // Move the selection to the middle of the inserted text node
            range.setStart(textNode, 1);
            range.setEnd(textNode, 1);
            sel.removeAllRanges();
            sel.addRange(range);
        }
    }
}, false);
like image 100
Tim Down Avatar answered Sep 19 '22 20:09

Tim Down


To accompish the goal stated in your summary, try altering the node value at the current cursor position. Since your code is attached to a keyup event, you can be assured that the range is already collapsed and on a text node (all of that happens on key down, which would have already fired).

function insertChar(char) {
    var range = window.getSelection().getRangeAt(0);
    if (range.startContainer.nodeType === Node.TEXT_NODE) {
        range.startContainer.insertData(range.startOffset, char);
    }
}

function handleKeyUp(e) {
    e = e || window.event;

    var char, keyCode;
    keyCode = e.keyCode;

    char =
        (e.shiftKey ? {
            "222": '"',
            "57":  ')',
            "219": '}'
        } : {
            "219": "]"
        })[keyCode] || null;

    if (char) {
        insertChar(char);
    }
}

document.getElementById("editable").onkeyup = handleKeyUp;

Fiddle

Also, I see that you were using innerHTML to set the new value (in Element.prototype.setText). This sounds alarming to me! innerHTML totally nukes whatever contents were previously in the container. Since the cursor is bound to a particular element, and those elements just got nuked, what is the browser supposed to do? Try to avoid using this if you care at all about where your cursor ends up afterwards.

As for the highlightText issue, it's hard to say why it is broken. Your fiddle does not show it being used anywhere, and I would need to see its usage to further diagnose it. However, I have an idea about what might be going wrong:

I think you should take a close look at getCaretPosition. You are treating this as though it returns the cursor position, but that isn't what it does. Remember, to a browser, your cursor position is always a range. It always has a beginning and an end. Sometimes, when the range is collapsed, the beginning and the end are the same point. However, the idea that you could get a cursor position and treat it as a single point is a dangerous oversimplification.

getCaretPosition has another issue. For your editable div, it does this:

  1. Selects all of the text in a new range
  2. Resets the new range's end position to equal the cursor's end position (so all text up to the cursor's end position is selected).
  3. Calls toString() and returns the length of the resulting string.

As you have noted, some elements (such as <br />) affect the results of toString(). Some elements (such as <span></span>) do not. In order to get this calculation right, you'll need to nudge it for some element types and not for others. This is going to be messy and complicated. If you want a number that you can feed into highlightText and have it work as expected, your current getCaretPosition is unlikely to be helpful.

Instead, I think you should try working directly with the cursor's start and end points as two separate locations, and update highlightText accordingly. Discard the current getCaretPosition and use the browser's native concepts of range.startContainer, range.startOffset, range.endContainer, and range.endOffset directly.

like image 22
Chris Nielsen Avatar answered Sep 20 '22 20:09

Chris Nielsen