user1636349
user1636349

Reputation: 548

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.

Upvotes: 0

Views: 172

Answers (3)

Mehdi
Mehdi

Reputation: 1747

I have used eventListener 'mousedown' together with 'mouseup' (which are fired at the beginning and end of text selection) together with the replace() method to replace innerHTML with desired included markup.

let selectedText

function getTheRange(){

    const selection = document.getSelection();
    if (selection.rangeCount > 0) {
        selectedText = selection.toString()
        return selectedText
    }
}


function changeHtml(){
        selectedText = getTheRange()
        let domContent = document.querySelector('p').innerHTML
        if(selectedText && selectedText.length>0){
            document.querySelector('p').innerHTML = domContent.replace(selectedText,'<div><mark>'+selectedText+'</mark></div>')
        }
}

//this is to avoid unwanted document multi-click selection
document.addEventListener('mousedown', (e)=>{  
    if(e.detail>1) e.preventDefault()
})
//------------------

document.addEventListener('mousedown', getTheRange)

document.addEventListener('mouseup', changeHtml)
<p>Hello the world</p>

Upvotes: 0

user1636349
user1636349

Reputation: 548

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);
}

Upvotes: -1

Sergey A Kryukov
Sergey A Kryukov

Reputation: 916

You never even need document.execCommand(). This function is deprecated, by the way.

Please consider this code sample:

const handleSelection = wrapIn => {
  const selection = window.getSelection();
  const node = selection.anchorNode;

  if (node != selection.focusNode)
        return; // just for simplicity
  if (node == null || node.constructor != Text)
    return; // just for simplicity
  if (selection.rangeCount != 1)
    return; // just for simplicity

  const parent = node.parentElement;
  const range = selection.getRangeAt(0);
  const before = node.textContent.slice(0, range.startOffset);
  const selected = node.textContent.slice(range.startOffset,
      range.endOffset);
  const after = node.textContent.slice(range.endOffset);

  parent.innerHTML =
      `${before}<${wrapIn}>${selected}</${wrapIn}>${after}`;
};

window.onload = () => {
  window.addEventListener('keydown', event => {
    if (event.ctrlKey && event.key == "y")
      handleSelection("b");
  });
};
b { font-size: 120%; }
bdi { color: red; }
<p>1234567890</p>
<p>123<bdi>45678</bdi>90</p>

This code snipped is greatly simplified, to keep it short. It wraps a fragment in bold <b></b> on the key gesture Ctrl+Y. I made the modified text bigger, by CSS styling b to make the effect more visible.

The implementation is not complete, for the sake of simplicity. It does not work correctly across elements. To modify the DOM in the second line, where I have <bdi>45678</bdi>, you can select the text only inside the fragment 45678 (colored, for the clearer demo), or only outside it. Also, the operation doesn't work correctly if your selection combines both <p> elements. I did not care about those cases just to keep this demo as simple as possible.

You may want to refine it by processing all selection ranges and all nodes inside the selection, not just one text-only node in just one range, as in my example.

Added:

As the inquirer wasn't satisfied with this simplified sample, I added a different, a bit more complicated one. It splits the inline element in two.

const handleSelection = () => {
    const selection = window.getSelection();
    const node = selection.anchorNode;
    if (node != selection.focusNode)
        return; // just for simplicity
    if (node == null || node.constructor != Text)
        return; // just for simplicity
    if (selection.rangeCount != 1)
        return; // just for simplicity
    const range = selection.getRangeAt(0);
    const before = node.textContent.slice(0, range.startOffset);
    const selected =
        node.textContent.slice(range.startOffset,
             range.endOffset);
    const after = node.textContent.slice(range.endOffset);
    const parent = node.parentElement;
    const wrapIn = parent.tagName.toLowerCase();
    console.log(selection.rangeCount);   
    const replacementNode = document.createElement("span");
    parent.replaceWith(replacementNode);
    replacementNode.innerHTML = `<${wrapIn}>${before}</${wrapIn}>${selected}<${wrapIn}>${after}</${wrapIn}>`;
};

window.onload = () => {
    window.addEventListener('keydown', event => {
        if (event.ctrlKey && event.key == "y")
            handleSelection("b");
    });
};
b { font-size: 120%; }
bdi { color: red; }
<p>1234567890</p>
<p>123<bdi>45678</bdi>90</p>

This more complicated code also needs further development. It does work as it is, but for any practical purpose, you really need to classify the types of parent and apply different processing for different types. For example, I demonstrate splitting <p> into two separate paragraphs, but the code adds another <span>, and this is required only for an inline element. What to do, depends on your purpose. Besides, if you repeat the same operations with different overlapping ranges, it will complicate and mess up HTML structure. Ideally, you need to analyze this structure and simplify it, and this is not so simple. I would suggest that the entire idea of your requirements is not the best design, so you better think of something more robust. But this is up to you.

Upvotes: 1

Related Questions