Search code examples
javascriptdomselection

How to split a DOM node around a selection?


I have a contenteditable div, and I want to split a node around a selection. Using execCommand(), I can toggle "bold" on or off for a selection, so if I have:

<b>ABCDEFGHI</b>

and select DEF, toggling "bold" gives me

<b>ABC</b>DEF<b>GHI</b>

where the <b> node has been split into two <b> nodes with a text node in between.

I want to be able to do the same with other elements not supported by execCommand(), for example <bdi>. In other words, if I start with

<bdi>ABCDEFGHI</bdi>

and select DEF, I want to end up with

<bdi>ABC</bdi>DEF<bdi>GHI</bdi>

I can test if the selection is contained in a surrounding <bdi> tag using range.commonAncestorContainer() and if not wrap the range in a <bdi> tag. However, what I want is the opposite: if there is an enclosing <bdi> node, I want to split it into (a) a well-formed <bdi> node before the selection, (b) a well-formed selection with no enclosing <bdi>, and (c) another well-formed <bdi> node after the selection, and then reassemble them. How can I do this?

EDIT: it seems that everyone believes I am trying to wrap a selection, but I'm not. Sergey's response below shows how to wrap some plain text, but I want something else.

Trying for a minimal reproducible example, consider the following:

<html>
<head></head>
<body>
  <b>This text is <i>marked as 
     bold
     with</i> some italic text too.</b>
</body>
</html>

Now what I want is to UNMARK the text "bold" so that the final result is:

<html>
<head></head>
<body>
  <b>This text is <i>marked as</i></b>
  <i>bold</i>
  <b><i>with</i> some italic text too.</b>
</body>
</html>

Note that the text includes <i>...</i>, which must also be split. This is trivially easy with execCommand(), but I can't figure out how to do it without execCommand() (and hence do it for tags like <bdi> as well). I'm looking for a vanilla JS solutsion, not jQuery or Rangy please.


Solution

  • I have finally come up with a solution: first I wrap the selection in a <span>. Then I work up the tree from the selection range's commonAncestorContainer to the node I want to replace (<bdi> in this case, but this will work with any other element as well). I then create two <bdi> elements (before and after) and copy the child nodes into before until I get to the <span> node I created, and copy the rest to after.

    Finally I use insertBefore() on the <bdi>'s parent node to insert before in front of <bdi>, then the child nodes of the <span> element, followed by after. I then remove the original <bdi>.

    Result: the original <bdi> element has been replaced by a well-formed <bdi> node, followed by some unwrapped nodes, followed by another well-formed <bdi> node.

    Oh, and if there is no enclosing <bdi> node, I just create one and use range.surroundContents() to wrap the selection, so that <bdi> is toggled on and off like execCommand() does for certain other tags.

    Here is the code:

    const copy = range.cloneContents();
    //
    //  Test if selection is inside a BDI
    //
    let bdi = null;
    for (let c = range.commonAncestorContainer; c != null; c = c.parentElement) {
      if (c.nodeType == 1 && c.nodeName == "BDI") {
        bdi = c;
        break;
      }
    }
    if (!!bdi) {
      //
      //  Wrap the range in a <span>
      //
      const span = document.createElement("span");
      span.appendChild(copy);
      range.deleteContents();
      range.insertNode(span);
      //
      //  Now split the enclosing BDI node before and after the <span> node
      //
      const before = document.createElement("bdi");
      const after  = document.createElement("bdi");
      let   found  = false;
      for (let c = bdi.firstChild; c != null; c = bdi.firstChild) {
        if (found) {
          after.appendChild(c);
        }
        else if (c == span) {
          found = true;
          bdi.removeChild(bdi.firstChild);
        }
        else {
          before.appendChild(c);
        }
      }
      //
      //  Now insert "before", the <span> node contents and "after"
      //  in front of the BDI node, and remove the original BDI node
      //
      const p = bdi.parentElement;
      p.insertBefore(before,bdi);
      for (var c = span.firstChild; c != null; c = span.firstChild) {
        p.insertBefore(c,bdi);
      }
      p.insertBefore(after,bdi);
      p.removeChild(bdi);
    }
    else {
      //
      //  No enclosing BDI, so wrap the selection
      //
      const bdi = document.createElement("bdi");
      range.deleteContents();
      range.insertNode(copy);
      range.surroundContents(bdi);
    }