0

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.

asked Jan 22, 2025 at 13:23
4
  • 2
    May you add a snippet to the question? Commented Jan 22, 2025 at 13:26
  • 2
    Not clear. Please read this: How to create a Minimal, Reproducible Example. The problem seems simple. It is absolutely unclear why would you use (deprecated!) document.execCommand(), ever. Commented Jan 22, 2025 at 13:30
  • This question is similar to: How To Wrap / Surround Highlighted Text With An Element. If you believe it’s different, please edit the question, make it clear how it’s different and/or how the answers on that question are not helpful for your problem. Commented Jan 22, 2025 at 14:42
  • @Poul Bak — I already answered this question. I think the question has some subtle differences, and my answer also adds a few useful points if compared with the existing answers. I would suggest that this question should not be removed. Commented Jan 22, 2025 at 15:12

3 Answers 3

1

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.

answered Jan 22, 2025 at 14:37
Sign up to request clarification or add additional context in comments.

12 Comments

thanks, but this is wrapping, not unwrapping: ABCDEFGHI to ABC<bdi>DEF</bdi>GHI or whatever. I'm trying to go from <bdi>ABCDEFGHI</bdi> to <bdi>ABC</bdi>DEF<bdi>GHI</bdi>.
@user1636349 — I wanted just to give you the idea on one sample, as simple as possible. You have everything to complete the code according to your a bit more complicated requirement. Are you saying that this conversion should happen if the selection is DEF? Could you do it yourself, or do you need a more complicated code sample? This is not hard to do, but it takes more time. Don't you think my demo is all you need to complete it?
@user1636349 — Okay, please see a more complicated code sample, in the section Added.
What happens if the selection is not just plain text, though? See my edited version of the original question where the html is <bdi...<i>...</i>...</bdi> and I want to extract part of the text inside <i>...</i> from the BDI element. You are extracting the text and splitting that, rather than splitting the DOM tree, which is what I require. Notice how my question asks how you split it into WELL-FORMED chunks.
"What happens if the selection is not just plain text, though?" I already answered. I added all you need to understand at the end of the recent revision of my solution. The main thing to understand is this: I already answered your question, and even a lot more, described all aspects. I won't be able to literally work for you more and more, you need to catch up and continue yourself. However, I question the merits of your requirements. At the same time, I'm ready to answer your further questions.
|
0

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>

halfer
20.2k20 gold badges111 silver badges208 bronze badges
answered Jan 22, 2025 at 22:21

1 Comment

I don't see how this relates to my question at all.
-1

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);
}
answered Jan 23, 2025 at 14:42

9 Comments

// Now split the enclosing BDI node before and after the <title> node. ~<title> element can only be in the <head> Do have any idea what you're actually doing?
I didn't update the comments from an earlier version. For <title>, read <span>. However, the code is correct. Is that the only reason you downvoted a perfectly good solution?
I have fixed the comment, but the code remains the same. (Incidentally, I was originally using <title> because I needed a valid element name for createElement() which wouldn't occur elsewhere so I could search for it, but then switched to using a <span> when I realised I have a reference to the element so don't have to search for it. OK?)
Prove it works by making a minimal reproducible example, Your description in answer makes very little sense as is your reason for using an element even a novice would know not to do or at least immediately realize that it was wrong the first time it was tried.
"...I would like to end up with bold text before and after the word "of", and only the word "of" de-bolded: <b>...</b>of<b>...</b>" It doesn't matter whether there are two separate elements and another between them or if there's a nested element. As long as the text is formatted correctly is the important factor. The elements in question are inline and by their very basic nature is the presentation of text.
|

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.