javascripthtmlcontenteditable

How to wrap text inside multiple nodes with a html tag


I'm working with a contenteditable and trying to create a simple editor. Unfortunately I cannot use document.execCommand() and have to implement it myself. What I'm trying to do here, is that if user presses the bold button, I want to make the text bold. The code I have written below works, but it only works when the selection is in one node, not multiple nodes.

document.getElementById("bold").onclick = function() {
  var selection = document.getSelection(),
      range = selection.getRangeAt(0).cloneRange();
  range.surroundContents(document.createElement("b"));
  selection.removeAllRanges();
  selection.addRange(range);
}
<div contenteditable="true" id="div">This is the editor. If you embolden only **this**, it will work. But if you try to embolden **this <i>and this**</i>, it will not work because they are in different nodes</div>
<button id="bold">Bold</button>

My question is: Is there a solution where I can click bold and it can embolden the text even if they are in different nodes? If so, how can I do this? I'm looking for something simple and elegant, but if it has to be complex, I would appreciate some explanation of the code. Thanks a lot.


Solution

  • This is not simple nor elegant but works as expected, without additional markup and is the best I can think of.

    Basically, you have to traverse the dom tree instersecting with the selection range, and collect sub ranges only composed of text node during traversal.

    Look into Range documentation for information on startContainer and endContainer. To put it simply they are the same when selecting into a single text node, and they give you the starting and ending point of your traversal otherwise.

    Once you have collected those ranges, you can wrap them in the tag of your liking.

    It works pretty well but unfortunately I wasn't able to preserve the initial selection after bolding (tried everything with selection.setRange(..) with no luck):

    document.getElementById("bold").onclick = function() {
        var selection = document.getSelection(),
        range = selection.getRangeAt(0).cloneRange();
        // start and end are always text nodes
        var start = range.startContainer;
        var end = range.endContainer;
        var ranges = [];
    
        // if start === end then it's fine we have selected a portion of a text node
        while (start !== end) {
    
            var startText = start.nodeValue;
            var currentRange = range.cloneRange();
            // pin the range at the end of this text node
            currentRange.setEnd(start, startText.length);
            // keep the range for later
            ranges.push(currentRange);
    
            var sibling = start;
            do {
                if (sibling.hasChildNodes()) {
                    // if it has children then it's not a text node, go deeper
                    sibling = sibling.firstChild;
                } else if (sibling.nextSibling) {
                    // it has a sibling, go for it
                    sibling = sibling.nextSibling;
                } else {
                    // we're into a corner, we have to go up one level and go for next sibling
                    while (!sibling.nextSibling && sibling.parentNode) {
                        sibling = sibling.parentNode;
                    }
                    if (sibling) {
                        sibling = sibling.nextSibling;
                    }
                }
            } while (sibling !== null && sibling.nodeValue === null);
            if (!sibling) {
                // out of nodes!
                break;
            }
            // move range start to the identified next text node (sibling)
            range.setStart(sibling, 0);
            start = range.startContainer;
        }
        // surround all collected range by the b tag
        for (var i = 0; i < ranges.length; i++) {
            var currentRange = ranges[i];
        currentRange.surroundContents(document.createElement("b"));
        }
        // surround the remaining range by a b tag
        range.surroundContents(document.createElement("b"));
    
        // unselect everything because I can't presere the original selection
        selection.removeAllRanges();
    
    }
    <div contenteditable="true" id="div">This is the editor. If you embolden only **this**, it will work. If you try <font color="red">to embolden **this <i>and this**</i>, it will work <font color="green">because</font> we are traversing the</font> nodes<table rules="all">
    <tr><td>it</td><td>will<td>even</td><td>work</td></tr>
    <tr><td>in</td><td>more</td><td>complicated</td><td><i>markup</i></td></tr>
    </table></div>
    <button id="bold">Bold</button>