Search code examples
jqueryregexreplacecontenteditable

jQuery: Convert text URL to link as typing


I'm making progress but not quite sure how to get this working right...

I have a contenteditable div which will function similar to a textarea.

I also have a regex to recognize URLs being typed in and automatically link them. I'm having trouble however making this work "live" as the user is typing.

Here is a jsFiddle of what I have so far. One other problem I am having is the cursor jumping to the beginning of the div after a link is entered (because I am replacing the div's .html()?)

Is there creative solution to use .replace() on individual strings of text in a div without having to replace the entire content of the div?


Solution

  • Firstly, IE will do this for you automatically.

    For other browsers, I would suggest doing the replacement after a period of user inactivity. Here's an answer of mine that illustrates how to do the replacement:

    https://stackoverflow.com/a/4045531/96100

    Here's a similar one with a (bad) link regex and discussion:

    https://stackoverflow.com/a/4026684/96100

    For saving and restoring the selection, I'd suggest using a character offset-based approach. There are shortcomings to the following code in general but for the particular case of saving and restoring a selection while changing formatting but leaving text unchanged, it's ideal. Here is an example:

    https://stackoverflow.com/a/13950376/96100

    Finally, here are a couple of answers with discussion and examples of how to wait for user inactivity:

    Putting it all together:

    var saveSelection, restoreSelection;
    
    if (window.getSelection && document.createRange) {
        saveSelection = function(containerEl) {
            var range = window.getSelection().getRangeAt(0);
            var preSelectionRange = range.cloneRange();
            preSelectionRange.selectNodeContents(containerEl);
            preSelectionRange.setEnd(range.startContainer, range.startOffset);
            var start = preSelectionRange.toString().length;
    
            return {
                start: start,
                end: start + range.toString().length
            }
        };
    
        restoreSelection = function(containerEl, savedSel) {
            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 && savedSel.start >= charIndex && savedSel.start <= nextCharIndex) {
                        range.setStart(node, savedSel.start - charIndex);
                        foundStart = true;
                    }
                    if (foundStart && savedSel.end >= charIndex && savedSel.end <= nextCharIndex) {
                        range.setEnd(node, savedSel.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) {
        saveSelection = function(containerEl) {
            var selectedTextRange = document.selection.createRange();
            var preSelectionTextRange = document.body.createTextRange();
            preSelectionTextRange.moveToElementText(containerEl);
            preSelectionTextRange.setEndPoint("EndToStart", selectedTextRange);
            var start = preSelectionTextRange.text.length;
    
            return {
                start: start,
                end: start + selectedTextRange.text.length
            }
        };
    
        restoreSelection = function(containerEl, savedSel) {
            var textRange = document.body.createTextRange();
            textRange.moveToElementText(containerEl);
            textRange.collapse(true);
            textRange.moveEnd("character", savedSel.end);
            textRange.moveStart("character", savedSel.start);
            textRange.select();
        };
    }
    
    function createLink(matchedTextNode) {
        var el = document.createElement("a");
        el.href = matchedTextNode.data;
        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);
        }
    }
    
    var textbox = document.getElementById("textbox");
    var urlRegex = /http(s?):\/\/($|[^\s]+)/;
    
    function updateLinks() {
        var savedSelection = saveSelection(textbox);
        surroundInElement(textbox, urlRegex, createLink, shouldLinkifyContents);
        restoreSelection(textbox, savedSelection);
    }
    
    var $textbox = $(textbox);
    
    $(document).ready(function () {
        $textbox.focus();
    
        var keyTimer = null, keyDelay = 1000;
    
        $textbox.keyup(function() {
            if (keyTimer) {
                window.clearTimeout(keyTimer);
            }
            keyTimer = window.setTimeout(function() {
                updateLinks();
                keyTimer = null;
            }, keyDelay);
        });
    });
    body { font:.8rem/1.5 sans-serif;  margin:2rem; }
    #textbox {
        border:thin solid gray;
        padding:1rem;
        height:10rem;
        margin:1rem 0;
        color:black;
        font-size:1rem;
    }
    a { color:blue; background:lightblue; }
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
    Start typing a message with a link i.e. <code>http://example.com</code>...
    <div id="textbox" contenteditable></div>