How To Wrap / Surround Highlighted Text With An Element
Asked Answered
P

8

58

I want to wrap a selected text in a div container with span, is it possible?

A user will select a text and will click a button, on button click event I want to wrap that selected text with span element. I can get the selected text using window.getSelection() but how to know its exact position in DOM structure?

Punchy answered 13/6, 2011 at 9:33 Comment(0)
C
78

If the selection is completely contained within a single text node, you can do this using the surroundContents() method of the range you obtain from the selection. However, this is very brittle: it does not work if the selection cannot logically be surrounded in a single element (generally, if the range crosses node boundaries, although this is not the precise definition). To do this in the general case, you need a more complicated approach.

Also, DOM Range and window.getSelection() are not supported in IE < 9. You'll need another approach again for those browsers. You can use a library such as my own Rangy to normalize browser behaviour and you may find the class applier module useful for this question.

Simple surroundContents() example jsFiddle: http://jsfiddle.net/VRcvn/

Code:

function surroundSelection(element) {
    if (window.getSelection) {
        var sel = window.getSelection();
        if (sel.rangeCount) {
            var range = sel.getRangeAt(0).cloneRange();
            range.surroundContents(element);
            sel.removeAllRanges();
            sel.addRange(range);
        }
    }
}
Courageous answered 13/6, 2011 at 9:51 Comment(4)
How can we wrap the selected text with a span if the selected text crosses node boundaries? Any solution (no matter how complicated) would be much appreciated.Gloxinia
@JoshGrinberg: That wouldn't be possible because you would have mismatched tags (e.g. <b>foo<i>bar</b>baz</i>). Probably, you'd first have to manipulate the already existing tags so that the area you want to wrap is only contained by one node.Escent
@Tim Down i am using your rangy library for creating a notes on selected text. I have almost done but stuck in one problem that i am showing a dot before selected text, but in case of multiple element, when i try to create note on that, it showing dot on multiple times, because of creating multiple span tag due to multiple elements. How can i apply any one class on only first node for selected text?Silver
@Tim Down is possible to display selected text? for example using innerHrml?Fetishism
A
38

function wrapSelectedText() {       
    var selection= window.getSelection().getRangeAt(0);
    var selectedText = selection.extractContents();
    var span= document.createElement("span");
    span.style.backgroundColor = "yellow";
    span.appendChild(selectedText);
    selection.insertNode(span);
}
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam rhoncus  gravida magna, quis interdum magna mattis quis. Fusce tempor sagittis  varius. Nunc at augue at erat suscipit bibendum id nec enim. Sed eu odio  quis turpis hendrerit sagittis id sit amet justo. Cras ac urna purus,  non rutrum nunc. Aenean nec vulputate ante. Morbi scelerisque sagittis  hendrerit. Pellentesque habitant morbi tristique senectus et netus et  malesuada fames ac turpis egestas. Nulla tristique ligula fermentum  tortor semper at consectetur erat aliquam. Sed gravida consectetur  sollicitudin. 

<input type="button" onclick="wrapSelectedText();" value="Highlight" />

JS Fiddle.

Abortive answered 13/6, 2011 at 9:46 Comment(1)
This is fine so long as the selection doesn't span multiple block level elements, and so long as you don't mind potentially getting nested spans. Also, an answer just containing a jsFiddle link will be no use at all if the linked page isn't working, so it's best to at least describe the approach in the answer.Courageous
O
6

Here's an attempt at a general solution that allows crossing element boundaries. Probably doesn't mix well with images, etc., nor non left-to-right text, but should be OK with simple cases.

First, here's a very general function for walking text nodes in the subtree defined by a Range object, from left to right. This will give us all the text we need as pieces:

function walkRange(range) {
    let ranges = [];
    
    let el = range.startContainer;
    let elsToVisit = true;
    while (elsToVisit) {
        let startOffset = el == range.startContainer ? range.startOffset : 0;
        let endOffset = el == range.endContainer ? range.endOffset : el.textContent.length;
        let r = document.createRange();
        r.setStart(el, startOffset);
        r.setEnd(el, endOffset);
        ranges.push(r);
        
        
        /// Move to the next text container in the tree order
        elsToVisit = false;
        while (!elsToVisit && el != range.endContainer) {
            let nextEl = getFirstTextNode(el.nextSibling);
            if (nextEl) {
                el = nextEl;
                elsToVisit = true;
            }
            else {
                if (el.nextSibling)      el = el.nextSibling;
                else if (el.parentNode)  el = el.parentNode;
                else                     break;
            }
        }
    }
    
    return ranges;
}

That makes use of this utility function for getting the first (leftmost) text node in a subtree:

function getFirstTextNode(el) {
    /// Degenerate cases: either el is null, or el is already a text node
    if (!el)               return null;
    if (el.nodeType == 3)  return el;
    
    for (let child of el.childNodes) {
        if (child.nodeType == 3) {
            return child;
        }
        else {
            let textNode = getFirstTextNode(child);
            if (textNode !== null) return textNode;
        }
    }
    
    return null;
}

Once you've called walkRanges, you can just use surroundContents on what it returns to actually do the highlighting/marking. Here it is in a function:

function highlight(range, className) {
    range = range.getRangeAt ? range.getRangeAt(0) : range;
    for (let r of walkRange(range)) {
        let mark = document.createElement('mark');
        mark.className = className;
        r.surroundContents(mark);
    }
}

and to unhighlight (assuming you used a unique class name for the highlight):

function unhighlight(sel) {
    document.querySelectorAll(sel).forEach(el => el.replaceWith(...el.childNodes));
}

Example usage:

highlight(document.getSelection(), 'mySelectionClassName');
unhighlight('.mySelectionClassName')
Orchidectomy answered 26/7, 2020 at 7:57 Comment(0)
F
5

Following works across multiple dom elements

function highlightSelection() {       
    let selection= window.getSelection().getRangeAt(0);
    let selectedContent = selection.extractContents();
    var span= document.createElement("span");
    span.style.backgroundColor = "lightpink";
    span.appendChild(selectedContent);
    selection.insertNode(span);
}
Make your <b>selection across multiple</b> elements <strike>and then click highlight</strike> button.

<button onclick="highlightSelection();">Highlight</button>
Ferryman answered 8/9, 2021 at 7:3 Comment(1)
This answer is significantly more elegant than the others.Candiecandied
D
4

it is possible. You need to use the range API and the Range.surroundContents() method. It places the node the content is wrapped in at the start of the specified range. see https://developer.mozilla.org/en/DOM/range.surroundContents

Declass answered 13/6, 2011 at 9:40 Comment(0)
C
4

surroundContents only works if your selection contains only text and no HTML. Here is a more flexible, as well as cross-browser solution. This will insert a span like this:

<span id="new_selection_span"><!--MARK--></span>

The span is inserted before the selection, in front of the nearest opening HTML tag.

var span = document.createElement("span");
span.id = "new_selection_span";
span.innerHTML = '<!--MARK-->';

if (window.getSelection) { //compliant browsers
    //obtain the selection
    sel = window.getSelection();
    if (sel.rangeCount) {
        //clone the Range object
        var range = sel.getRangeAt(0).cloneRange();
        //get the node at the start of the range
        var node = range.startContainer;
        //find the first parent that is a real HTML tag and not a text node
        while (node.nodeType != 1) node = node.parentNode;
        //place the marker before the node
        node.parentNode.insertBefore(span, node);
        //restore the selection
        sel.removeAllRanges();
        sel.addRange(range);
    }
} else { //IE8 and lower
    sel = document.selection.createRange();
    //place the marker before the node
    var node = sel.parentElement();
    node.parentNode.insertBefore(span, node);
    //restore the selection
    sel.select();
}
Ciao answered 4/2, 2012 at 1:21 Comment(1)
If you want to surround a selection that contains no HTML elements, the accepted solution works best. This solution will help you identify an already existing node that contains the selected text, by placing a marking span just before the target element.Ciao
F
2

Please find the below code will be helpfull for wrapping the span tag for all kind of tags. Please go through the code and use the logic for your implementation.

getSelectedText(this);
addAnnotationElement(this, this.parent);

function getSelectedText(this) {
    this.range = window.getSelection().getRangeAt(0);
    this.parent = this.range.commonAncestorContainer;
    this.frag = this.range.cloneContents();
    this.clRange = this.range.cloneRange();
    this.start = this.range.startContainer;
    this.end = this.range.endContainer;
}


function addAnnotationElement(this, elem) {
    var text, textParent, origText, prevText, nextText, childCount,
        annotationTextRange,
        span = this.htmlDoc.createElement('span');

    if (elem.nodeType === 3) {
        span.setAttribute('class', this.annotationClass);
        span.dataset.name = this.annotationName;
        span.dataset.comment = '';
        span.dataset.page = '1';
        origText = elem.textContent;            
        annotationTextRange = validateTextRange(this, elem);
        if (annotationTextRange == 'textBeforeRangeButIntersect') {
            text = origText.substring(0, this.range.endOffset);
            nextText = origText.substring(this.range.endOffset);
        } else if (annotationTextRange == 'textAfterRangeButIntersect') {
            prevText = origText.substring(0, this.range.startOffset);
            text = origText.substring(this.range.startOffset);
        } else if (annotationTextRange == 'textExactlyInRange') {
            text = origText
        } else if (annotationTextRange == 'textWithinRange') {
            prevText = origText.substring(0, this.range.startOffset);
            text = origText.substring(this.range.startOffset,this.range.endOffset);
            nextText = origText.substring(this.range.endOffset);
        } else if (annotationTextRange == 'textNotInRange') {
            return;
        }
        span.textContent = text;
        textParent = elem.parentElement;
        textParent.replaceChild(span, elem);
        if (prevText) {
            var prevDOM = this.htmlDoc.createTextNode(prevText);
            textParent.insertBefore(prevDOM, span);
        }
        if (nextText) {
            var nextDOM = this.htmlDoc.createTextNode(nextText);
            textParent.insertBefore(nextDOM, span.nextSibling);
        }
        return;
    }
    childCount = elem.childNodes.length;
    for (var i = 0; i < childCount; i++) {
        var elemChildNode = elem.childNodes[i];
        if( Helper.isUndefined(elemChildNode.tagName) ||
            ! ( elemChildNode.tagName.toLowerCase() === 'span' &&
            elemChildNode.classList.contains(this.annotationClass) ) ) {
            addAnnotationElement(this, elem.childNodes[i]);
        }
        childCount = elem.childNodes.length;
    }
}

  function validateTextRange(this, elem) {
    var textRange = document.createRange();

    textRange.selectNodeContents (elem);
    if (this.range.compareBoundaryPoints (Range.START_TO_END, textRange) <= 0) {
        return 'textNotInRange';
    }
    else {
        if (this.range.compareBoundaryPoints (Range.END_TO_START, textRange) >= 0) {
            return 'textNotInRange';
        }
        else {
            var startPoints = this.range.compareBoundaryPoints (Range.START_TO_START, textRange),
                endPoints = this.range.compareBoundaryPoints (Range.END_TO_END, textRange);

            if (startPoints < 0) {
                if (endPoints < 0) {
                    return 'textBeforeRangeButIntersect';
                }
                else {
                    return "textExactlyInRange";
                }
            }
            else {
                if (endPoints > 0) {
                    return 'textAfterRangeButIntersect';
                }
                else {
                    if (startPoints === 0 && endPoints === 0) {
                        return "textExactlyInRange";
                    }
                    else {
                        return 'textWithinRange';
                    }
                }
            }
        }
    }
}
Farceur answered 16/11, 2016 at 10:32 Comment(1)
Welcome to StackOverflow! A great answer explains what the code is doing so that people can learn from it rather than simply being a huge code block. You can improve your answer by highlighting the important parts.Patron
S
0

The following code wraps all text nodes inside the current selection in span-elements. It works even if the selection spans across several deeply nested elements and ignores non-text nodes.

This answer uses several techniques not yet available at the time of previous answers, most notably a nodeIterator to efficiently parse over all text-nodes.

It retains the selection and supports Firefox's feature to select several independent text ranges.

function wrapSelectedTextNodes(id, className) {
    getSelectedTextNodes().forEach((selection, index) => {
        selection.forEach((textNode, nodeNumber) => {
            let span = document.createElement('span');
            if (nodeNumber==0) span.id=id+"-"+index;
            else span.setAttribute("for",id+"-"+index);
            span.classList.add(className);
            textNode.before(span);
            span.appendChild(textNode);
        });
    });
}

function getSelectedTextNodes() {
    let returnArray = new Array();
    let selection = window.getSelection();
    for (let rangeNumber = selection.rangeCount-1; rangeNumber >= 0; rangeNumber--) {
        let rangeNodes = new Array();
        let range = selection.getRangeAt(rangeNumber);
        if (range.startContainer === range.endContainer && range.endContainer.nodeType === Node.TEXT_NODE) {
            range.startContainer.splitText(range.endOffset);
            let textNode = range.startContainer.splitText(range.startOffset);
            rangeNodes.push(textNode);
        } else {
            let textIterator = document.createNodeIterator(range.commonAncestorContainer, NodeFilter.SHOW_TEXT, (node) => (node.compareDocumentPosition(range.startContainer)==Node.DOCUMENT_POSITION_PRECEDING && node.compareDocumentPosition(range.endContainer)==Node.DOCUMENT_POSITION_FOLLOWING) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT );
            while (node = textIterator.nextNode()) { if (node.textContent.trim()!="") rangeNodes.push(node);}
            if (range.endContainer.nodeType === Node.TEXT_NODE) {
                range.endContainer.splitText(range.endOffset);
                rangeNodes.push(range.endContainer);
            }
            if (range.startContainer.nodeType === Node.TEXT_NODE) {
                rangeNodes.unshift(range.startContainer.splitText(range.startOffset));
            }
        }
        returnArray.unshift(rangeNodes);
    }
    return returnArray;
}
.selection { background-color:pink }

table { border-collapse:collapse }
td { border:1px solid black; padding:0.3em}
<p>Select any text (or in Firefox, multiple passages using Ctrl) across the different elements below. Then hit the "Highlight Selection" button.</p>
<p>This will wrap the selected text ins span-nodes and color it pink. It works for partially selected nodes and will keep the remaining dom-layout intact</p>
<ul>
  <li>it will work on lists</li>
  <li>and won't mess them up</li>
</ul>
<ol>
  <li>even if they are themselves nested around other complex elements</li>
  <li>
  <table>
      <tr>
        <td>such</td><td>as</td><td>tables</td>
        <td colspan=2>with weird</td><td>layout</td>
      </tr>
    </table>
  </li>
</ol>
<p>or <strong>with <i>deeply <u>nested</u> elements</i> like </strong>this.</p>
<button onclick="wrapSelectedTextNodes('mySelectionId','selection')">Highlight Selection</button>

The bulk of the work is done in the function getSelectedTextNodes, which can be used independently. It splits up partially selected text nodes, so the result will no longer have partially selected text nodes.

The wrapper function wrapSelectedTextNodes is rather partial to my own usage. You probably need to adjust it (attributes and ids may not be useful for you). However, keep my method to wrap the text node in a new span node, as this is the only method that keeps the selection intact.

Slimsy answered 26/5, 2023 at 18:21 Comment(1)
A fuller version that can intelligently wrap white-space only text nodes if they are rendered by browser, and that takes CSS user-select:none; into account, can be found here: gist.github.com/ddbb6661b2a8223126ca1f3afc894c74 An example of this in action can be seen here: jsfiddle.net/s0etmpnfSlimsy

© 2022 - 2024 — McMap. All rights reserved.