views:

155

answers:

3

I'm working on a jQuery plugin that will allow you to do @username style tags, like Facebook does in their status update input box.

My problem is, that even after hours of researching and experimenting, it seems REALLY hard to simply move the caret. I've managed to inject the <a> tag with someone's name, but placing the caret after it seems like rocket science, specially if it's supposed work in all browsers.

And I haven't even looked into replacing the typed @username text with the tag yet, rather than just injecting it as I'm doing right now... lol

There's a ton of questions about working with contenteditable here on Stack Overflow, and I think I've read all of them, but they don't really cover properly what I need. So any more information anyone can provide would be great :)

A: 

As you say you can already insert an tag at the caret, I'm going to start from there. The first thing to do is to give your tag an id when you insert it. You should then have something like this:

<div contenteditable='true' id='status'>I went shopping with <a href='#' id='atagid'>Jane</a></div>

Here is a function that should place the cursor just after the tag.

function setCursorAfterA()
{
    var atag = document.getElementById("atagid");
    var parentdiv = document.getElementById("status");
    var range,selection;
    if(window.getSelection) //FF,Chrome,Opera,Safari,IE9+
    {
        parentdiv.appendChild(document.createTextNode(""));//FF wont allow cursor to be placed directly between <a> tag and the end of the div, so a space is added at the end (this can be trimmed later)
        range = document.createRange();//create range object (like an invisible selection)
        range.setEndAfter(atag);//set end of range selection to just after the <a> tag
        range.setStartAfter(atag);//set start of range selection to just after the <a> tag
        selection = window.getSelection();//get selection object (list of current selections/ranges)
        selection.removeAllRanges();//remove any current selections (FF can have more than one)
        parentdiv.focus();//Focuses contenteditable div (necessary for opera)
        selection.addRange(range);//add our range object to the selection list (make our range visible)
    }
    else if(document.selection)//IE 8 and lower
    { 
        range = document.body.createRange();//create a "Text Range" object (like an invisible selection)
        range.moveToElementText(atag);//select the contents of the a tag (i.e. "Jane")
        range.collapse(false);//collapse selection to end of range (between "e" and "</a>").
        while(range.parentElement() == atag)//while ranges cursor is still inside <a> tag
        {
             range.move("character",1);//move cursor 1 character to the right
        }
        range.move("character",-1);//move cursor 1 character to the left
        range.select()//move the actual cursor to the position of the ranges cursor
    }
    /*OPTIONAL: 
    atag.id = ""; //remove id from a tag
    */
}

EDIT: Tested and fixed script. It definitely works in IE6, chrome 8, firefox 4, and opera 11. Don't have other browsers on hand to test, but it doesn't use any functions that have changed recently so it should work in anything that supports contenteditable.

This button is handy for testing: <input type='button' onclick='setCursorAfterA()' value='Place Cursor After &lt;a/&gt; tag' >

Nico

Nico Burns
+2  A: 

You could use my Rangy library, which attempts with some success to normalize browser range and selection implementations. If you've managed to insert the <a> as you say and you've got it in a variable called aElement, you can do the following:

var range = rangy.createRange();
range.setStartAfter(aElement);
range.collapse(true);
var sel = rangy.getSelection();
sel.removeAllRanges();
sel.addRange(range);
Tim Down
+1 for the library, this will be a great help
Bertvan
Your question has got me interested and I've been writing some generic code for doing this kind of thing. I'll post back soon.
Tim Down
+1  A: 

I got interested in this, so I've written a fairly complete solution. The following uses my Rangy library with its selection save/restore module to save and restore the selection and normalize cross browser issues. It surrounds all matching text (@whatever in this case) with a link element and positions the selection where it had been previously. This is triggered after there has been no keyboard activity for one second. It should be quite reusable.

function createLink(matchedTextNode) {
    var el = document.createElement("a");
    el.style.backgroundColor = "yellow";
    el.style.padding = "2px";
    el.contentEditable = false;
    var matchedName = matchedTextNode.data.slice(1); // Remove the leading @
    el.href = "http://www.example.com/?name=" + matchedName;
    matchedTextNode.data = matchedName;
    el.appendChild(matchedTextNode);
    return el;
}

function shouldLinkifyContents(el) {
    return el.tagName != "A";
}

function surroundInElement(el, regex, surrounderCreateFunc, shouldSurroundFunc) {
    var child = el.lastChild;
    while (child) {
        if (child.nodeType == 1 && shouldSurroundFunc(el)) {
            surroundInElement(child, regex, createLink, shouldSurroundFunc);
        } else if (child.nodeType == 3) {
            surroundMatchingText(child, regex, surrounderCreateFunc);
        }
        child = child.previousSibling;
    }
}

function surroundMatchingText(textNode, regex, surrounderCreateFunc) {
    var parent = textNode.parentNode;
    var result, surroundingNode, matchedTextNode, matchLength, matchedText;
    while ( textNode && (result = regex.exec(textNode.data)) ) {
        matchedTextNode = textNode.splitText(result.index);
        matchedText = result[0];
        matchLength = matchedText.length;
        textNode = (matchedTextNode.length > matchLength) ?
            matchedTextNode.splitText(matchLength) : null;
        surroundingNode = surrounderCreateFunc(matchedTextNode.cloneNode(true));
        parent.insertBefore(surroundingNode, matchedTextNode);
        parent.removeChild(matchedTextNode);
    }
}

function updateLinks() {
    var el = document.getElementById("editable");
    var savedSelection = rangy.saveSelection();
    surroundInElement(el, /@\w+/, createLink, shouldLinkifyContents);
    rangy.restoreSelection(savedSelection);
}

var keyTimer = null, keyDelay = 1000;

function keyUpLinkifyHandler() {
    if (keyTimer) {
        window.clearTimeout(keyTimer);
    }
    keyTimer = window.setTimeout(function() {
        updateLinks();
        keyTimer = null;
    }, keyDelay);
}

HTML:

<p contenteditable="true" id="editable" onkeyup="keyUpLinkifyHandler()">
    Some editable content for @someone or other
</p>
Tim Down