Tomasz Lisiecki
Tomasz Lisiecki

Reputation: 71

How to make range offset to work with HTML elements in a multiple line contenteditable div?

I am having a few issues with my code regarding caret positioning, content editable div and HTML tags in it.

What I am trying to achieve

I'd like to have a content editable div, which allows for line breaks and multiple HTML tags inserted by typing some sort of shortcut - double left bracket '{{' in my case.

What I have achieved so far

The div allows for a single HTML tag and only works in a single line of text.

The issues

1) When I break the line with the return key, the {{ no longer triggers the tag to show up. I assume that you have to somehow make the script to take line breaks (nodes?) into account when creating the range.

2) If you already have one HTML tag visible, you can't insert another one. Instead, you get the following error in browser's console.

Uncaught DOMException: Failed to execute 'setStart' on 'Range': The offset 56 is larger than the node's length (33).

I noticed that range offset goes to 0 (or starts with the end of HTML tag) which is probably at the culprit of the issue here.

Below is the code I have so far...

Everything is triggered on either keyup or mouseclick.

var tw_template_trigger = '{{';
var tw_template_tag = '<span class="tw-template-tag" contenteditable="false"><a href="#" class="tw-template-tag-remove"><i class="tw-icon tw-icon-close"></i></a>Pick a tag</span>';

$('.tw-post-template-content').on( 'keyup mouseup', function() {

    // Basically check if someone typed {{ 
    // if yes, attempt to delete those two characters
    // then paste tag HTML in that position
    if( checkIfTagIsTriggered( this ) && deleteTagTrigger( this ) ) {
        pasteTagAtCaret();
    }   

});


function pasteTagAtCaret(selectPastedContent) {

    // Then add the tag
    var sel, range;
    if (window.getSelection) {
        // IE9 and non-IE
        sel = window.getSelection();
        if (sel.getRangeAt && sel.rangeCount) {
            range = sel.getRangeAt(0);
            range.deleteContents();

            // Range.createContextualFragment() would be useful here but is
            // only relatively recently standardized and is not supported in
            // some browsers (IE9, for one)
            var el = document.createElement("div");
            el.innerHTML = tw_template_tag;
            var frag = document.createDocumentFragment(), node, lastNode;
            while ( (node = el.firstChild) ) {
                lastNode = frag.appendChild(node);
            }
            var firstNode = frag.firstChild;
            range.insertNode(frag);

            // Preserve the selection
            if (lastNode) {
                range = range.cloneRange();
                range.setStartAfter(lastNode);
                range.collapse(true);
                sel.removeAllRanges();
                sel.addRange(range);
            }
        }
    } else if ( (sel = document.selection) && sel.type != "Control") {
        // IE < 9
        var originalRange = sel.createRange();
        originalRange.collapse(true);
        sel.createRange().pasteHTML( tw_template_tag );
    }

}

function checkIfTagIsTriggered(containerEl) {

    var precedingChar = "", sel, range, precedingRange;
    if (window.getSelection) {
        sel = window.getSelection();
        if (sel.rangeCount > 0) {
            range = sel.getRangeAt(0).cloneRange();
            range.collapse(true);
            range.setStart(containerEl, 0);
            precedingChar = range.toString().slice(-2);
        }
    } else if ( (sel = document.selection) && sel.type != "Control") {
        range = sel.createRange();
        precedingRange = range.duplicate();
        precedingRange.moveToElementText(containerEl);
        precedingRange.setEndPoint("EndToStart", range);
        precedingChar = precedingRange.text.slice(-2);
    }

    if( tw_template_trigger == precedingChar )
        return true;

    return false;

}

function deleteTagTrigger(containerEl) {

    var preceding = "",
        sel,
        range,
        precedingRange;
    if (window.getSelection) {
        sel = window.getSelection();
        if (sel.rangeCount > 0) {
            range = sel.getRangeAt(0).cloneRange();
            range.collapse(true);
            range.setStart(containerEl, 0);
            preceding = range.toString();
        }
    } else if ((sel = document.selection) && sel.type != "Control") {
        range = sel.createRange();
        precedingRange = range.duplicate();
        precedingRange.moveToElementText(containerEl);
        precedingRange.setEndPoint("EndToStart", range);
        preceding = precedingRange.text;
    }

    // First Remove {{
    var words = range.toString().trim().split(' '),
    lastWord = words[words.length - 1];

    if (lastWord && lastWord == tw_template_trigger ) {

        /* Find word start and end */
        var wordStart = range.toString().lastIndexOf(lastWord);
        var wordEnd = wordStart + lastWord.length;

        range.setStart(containerEl.firstChild, wordStart);
        range.setEnd(containerEl.firstChild, wordEnd);

        range.deleteContents();
        range.insertNode(document.createTextNode(' '));
        // delete That specific word and replace if with resultValue

        return true;

    }

    return false;

}

I noticed that those two lines are causing the browser error in the second issue

range.setStart(containerEl.firstChild, wordStart);
range.setEnd(containerEl.firstChild, wordEnd);

Theoretically, I know what the issue is. I believe both issues could be solved by making the range-creating script to use parent node rather than children nodes and also to loop through text nodes which line breaks are. However, I don't have a clue how to implement it at this point.

Could you please point me into the right direction?

Edit

I've actually managed to upload a demo with the progress so far to make it more clear.

Demo

Upvotes: 3

Views: 897

Answers (1)

Tomasz Lisiecki
Tomasz Lisiecki

Reputation: 71

I solved the problem myself and merged all functions into one. Neat! Below is the final code. I removed the ability to press enter after further considering it.

Hope it helps someone

    var tw_template_trigger = '{{';
    var tw_template_tag = '<span class="tw-template-tag" contenteditable="false">Pick a tag</span>';

    $(".tw-post-template-content").keypress(function(e){ return e.which != 13; });

    $('.tw-post-template-content').on( 'keyup mouseup', function() {
        triggerTag( this ); 
    });

    function triggerTag(containerEl) {

        var sel,
            range,
            text;

        if (window.getSelection) {
            sel = window.getSelection();
            if (sel.rangeCount > 0) {
                range = sel.getRangeAt(0).cloneRange(); // clone current range into another variable for manipulation#
                range.collapse(true);
                range.setStart(containerEl, 0);
                text = range.toString();
            }
        }

        if( text && text.slice(-2) == tw_template_trigger ) {
            range.setStart( range.endContainer, range.endOffset - tw_template_trigger.length);
            range.setEnd( range.endContainer, range.endOffset );
            range.deleteContents();
            range.insertNode(document.createTextNode(' '));

            //

            var el = document.createElement("div");
            el.innerHTML = tw_template_tag;
            var frag = document.createDocumentFragment(), node, lastNode;
            while ( (node = el.firstChild) ) {
                lastNode = frag.appendChild(node);
            }
            var firstNode = frag.firstChild;
            range.insertNode(frag);

            // Preserve the selection
            if (lastNode) {
                range = range.cloneRange();
                range.setStartAfter(lastNode);
                range.collapse(true);
                sel.removeAllRanges();
                sel.addRange(range);
            }

            return true;
        }

        return false;

    }

Upvotes: 2

Related Questions