45

I would like to highlight (apply css to) a certain text range, denoted by its start and end position. This is more diffucult than it seems, since there may be other tags within the text, that need to be ignored.

Example:

<div>abcd<em>efg</em>hij</div>

highlight(2, 6) needs to highlight "cdef" without removing the tag.

I have tried already using a TextRange object, but without success.

Thanks in advance!

Niklas
  • 29,752
  • 5
  • 50
  • 71
Vincent
  • 1,126
  • 2
  • 10
  • 11
  • Can you strip the tags in a temporary string, and then substring from that string? – Kevin Ji Jun 05 '11 at 00:28
  • You can't just ignore tags, otherwise you will end up with not valid html: `abcdeff`. You would need to do something like `abcdefg` – serg Jun 05 '11 at 00:37
  • 1
    Of course I can't ignore the tags, but it would be nice if the browser could solve those problems for me in some way. – Vincent Jun 05 '11 at 11:03

5 Answers5

70

Below is a function to set the selection to a pair of character offsets within a particular element. This is naive implementation: it does not take into account any text that may be made invisible (either by CSS or by being inside a <script> or <style> element, for example) and may have browser discrepancies (IE versus everything else) with line breaks, and takes no account of collapsed whitespace (such as 2 or more consecutive space characters collapsing to one visible space on the page). However, it does work for your example in all major browsers.

For the other part, the highlighting, I'd suggest using document.execCommand() for that. You can use my function below to set the selection and then call document.execCommand(). You'll need to make the document temporarily editable in non-IE browsers for the command to work. See my answer here for code: getSelection & surroundContents across multiple tags

Here's a jsFiddle example showing the whole thing, working in all major browsers: http://jsfiddle.net/8mdX4/1211/

And the selection setting code:

function getTextNodesIn(node) {
    var textNodes = [];
    if (node.nodeType == 3) {
        textNodes.push(node);
    } else {
        var children = node.childNodes;
        for (var i = 0, len = children.length; i < len; ++i) {
            textNodes.push.apply(textNodes, getTextNodesIn(children[i]));
        }
    }
    return textNodes;
}

function setSelectionRange(el, start, end) {
    if (document.createRange && window.getSelection) {
        var range = document.createRange();
        range.selectNodeContents(el);
        var textNodes = getTextNodesIn(el);
        var foundStart = false;
        var charCount = 0, endCharCount;

        for (var i = 0, textNode; textNode = textNodes[i++]; ) {
            endCharCount = charCount + textNode.length;
            if (!foundStart && start >= charCount
                    && (start < endCharCount ||
                    (start == endCharCount && i <= textNodes.length))) {
                range.setStart(textNode, start - charCount);
                foundStart = true;
            }
            if (foundStart && end <= endCharCount) {
                range.setEnd(textNode, end - charCount);
                break;
            }
            charCount = endCharCount;
        }

        var sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(range);
    } else if (document.selection && document.body.createTextRange) {
        var textRange = document.body.createTextRange();
        textRange.moveToElementText(el);
        textRange.collapse(true);
        textRange.moveEnd("character", end);
        textRange.moveStart("character", start);
        textRange.select();
    }
}
Community
  • 1
  • 1
Tim Down
  • 318,141
  • 75
  • 454
  • 536
  • 2
    Brilliant solution @tim-down! Your code was adapted to consolidating nested HTML formatted text. http://stackoverflow.com/questions/16226671/consolidate-stacked-dom-formatting-elements-contenteditable-div – Mike Wolfe Apr 26 '13 at 20:59
  • When I run this code a span tag is added to all the text which is highlighted , Can I add a class and a click event to that span tag ? – Prateek Sep 13 '13 at 09:15
  • @prateek: I assume you mean the highlighting code? Since the spans are added automatically by the browser, it's not easy to find them and modify them. You could use the [class applier module](https://code.google.com/p/rangy/wiki/CSSClassApplierModule) of my [Rangy](https://code.google.com/p/rangy) library instead. – Tim Down Sep 13 '13 at 09:23
  • @TimDown isn't this possible while creating the span tags I can add some attributes and event like you have added background color – Prateek Sep 13 '13 at 09:26
  • @prateek: The spans that apply the background colour are generated by the browser in response to a simple call of `document.execCommand(...)`. You could search for spans by checking the computed value of `background-color` for each span in the document, as in http://stackoverflow.com/questions/8076341/remove-highlight-added-to-selected-text-using-javascript/8106283#8106283 – Tim Down Sep 13 '13 at 09:32
  • @TimDown thanks for your suggestion it works fine in my case but when the hierarchy of tags is more it doesn't check this fiddle I am using your javascript code here. http://jsfiddle.net/Bvd9d/47/ – Prateek Sep 13 '13 at 10:17
  • This has a bug in it. This test: (start == endCharCount && i < textNodes.length) Should be: (start == endCharCount && i <= textNodes.length) Because you've already incremented i in the loop (so for the last text node, it will equal textNodes.length at this point). – Brandon Paddock Jun 16 '15 at 00:04
  • @BrandonPaddock: Good spot, thanks. I usually avoid using that form of `for` loop unless I know I don't need to use the loop counter, for precisely that reason. – Tim Down Jun 16 '15 at 09:24
  • @TimDown thats a super solution and i am using this functionality its working fine for me. i am also highlight text by document.execcommand function. But can you give a solution how can i remove background color or highlighted text again which i added thorugh document.execcommand ? – Gitesh Purbia Apr 17 '17 at 05:30
  • @TimDown - thank you so much for wonderful code. I have requirement to highlight different ranges in same document. When I use selectAndHighlightRange() multiple times. It doesn't show same yellow color on all. How can I fix that? – Amod Gokhale Jun 10 '21 at 06:20
3

You could take a look at how works this powerful JavaScript utility which support selection over multiple DOM elements:

MASHA (short for Mark & Share) allow you to mark interesting parts of web page content and share it

http://mashajs.com/index_eng.html

It's also on GitHub https://github.com/SmartTeleMax/MaSha

Works even on Mobile Safari and IE!

0

Based on the ideas of the jQuery.highlight plugin.

    private highlightRange(selector: JQuery, start: number, end: number): void {
        let cur = 0;
        let replacements: { node: Text; pos: number; len: number }[] = [];

        let dig = function (node: Node): void {
            if (node.nodeType === 3) {
                let nodeLen = (node as Text).data.length;
                let next = cur + nodeLen;
                if (next > start && cur < end) {
                    let pos = cur >= start ? cur : start;
                    let len = (next < end ? next : end) - pos;
                    if (len > 0) {
                        if (!(pos === cur && len === nodeLen && node.parentNode &&
                            node.parentNode.childNodes && node.parentNode.childNodes.length === 1 &&
                            (node.parentNode as Element).tagName === 'SPAN' && (node.parentNode as Element).className === 'highlight1')) {

                            replacements.push({
                                node: node as Text,
                                pos: pos - cur,
                                len: len,
                            });
                        }
                    }
                }
                cur = next;
            }
            else if (node.nodeType === 1) {
                let childNodes = node.childNodes;
                if (childNodes && childNodes.length) {
                    for (let i = 0; i < childNodes.length; i++) {
                        dig(childNodes[i]);
                        if (cur >= end) {
                            break;
                        }
                    }
                }
            }
        };

        selector.each(function (index, element): void {
            dig(element);
        });

        for (let i = 0; i < replacements.length; i++) {
            let replacement = replacements[i];
            let highlight = document.createElement('span');
            highlight.className = 'highlight1';
            let wordNode = replacement.node.splitText(replacement.pos);
            wordNode.splitText(replacement.len);
            let wordClone = wordNode.cloneNode(true);
            highlight.appendChild(wordClone);
            wordNode.parentNode.replaceChild(highlight, wordNode);
        }
    }
Bill
  • 409
  • 1
  • 6
  • 6
0

Following solution doesn't work for IE, you'll need to apply TextRange objects etc. for that. As this uses selections to perform this, it shouldn't break the HTML in normal cases, for example:

<div>abcd<span>efg</span>hij</div>

With highlight(3,6);

outputs:

<div>abc<em>d<span>ef</span></em><span>g</span>hij</div>

Take note how it wraps the first character outside of the span into an em, and then the rest within the span into a new one. Where as if it would just open it at character 3 and end at character 6, it would give invalid markup like:

<div>abc<em>d<span>ef</em>g</span>hij</div>

The code:

var r = document.createRange();
var s = window.getSelection()

r.selectNode($('div')[0]);
s.removeAllRanges();
s.addRange(r);

// not quite sure why firefox has problems with this
if ($.browser.webkit) {
    s.modify("move", "backward", "documentboundary");
}

function highlight(start,end){
    for(var st=0;st<start;st++){
        s.modify("move", "forward", "character");
    }

    for(var st=0;st<(end-start);st++){
        s.modify("extend", "forward", "character");
    }
}

highlight(2,6);

var ra = s.getRangeAt(0);
var newNode = document.createElement("em");
newNode.appendChild(ra.extractContents()); 
ra.insertNode(newNode);

Example: http://jsfiddle.net/niklasvh/4NDb9/

edit Looks like at least my FF4 had some issues with

s.modify("move", "backward", "documentboundary");

but at the same time, it seems to work without it, so I just changed it to

if ($.browser.webkit) {
        s.modify("move", "backward", "documentboundary");
}

edit as Tim Pointed out, modify is only available from FF4 onwards, so I took a different approach to getting the selection, which doesn't need the modify method, in hopes in making it a bit more browser compatible (IE still needs its own solution).

The code:

var r = document.createRange();
var s = window.getSelection()

var pos = 0;

function dig(el){
    $(el).contents().each(function(i,e){
        if (e.nodeType==1){
            // not a textnode
         dig(e);   
        }else{
            if (pos<start){
               if (pos+e.length>=start){
                range.setStart(e, start-pos);
               }
            }

            if (pos<end){
               if (pos+e.length>=end){
                range.setEnd(e, end-pos);
               }
            }            

            pos = pos+e.length;
        }
    });  
}
var start,end, range;

function highlight(element,st,en){
    range = document.createRange();
    start = st;
    end = en;
    dig(element);
    s.addRange(range);

}
highlight($('div'),3,6);

var ra = s.getRangeAt(0);

var newNode = document.createElement("em");
newNode.appendChild(ra.extractContents()); 
ra.insertNode(newNode);

example: http://jsfiddle.net/niklasvh/4NDb9/

Niklas
  • 29,752
  • 5
  • 50
  • 71
  • Firefox only implements `Selection.modify()` since Firefox 4.0 and doesn't support all the granularity settings that WebKit does. Specifically, it doesn't support "documentboundary". See https://developer.mozilla.org/en/DOM/selection/modify – Tim Down Jun 05 '11 at 10:15
  • This seems to work only if there is nothing else on the page. See for example: http://jsfiddle.net/4NDb9/89/. Although Hello world is outside the div, it is being highlighted, instead of the text inside the div. – Vincent Jun 05 '11 at 11:02
  • @Tim Down Very good point. I rewrote a good portion of it to get rid of the modify() method now. – Niklas Jun 05 '11 at 11:05
  • @Vincent Should be sorted in this rewritten code, have a look at http://jsfiddle.net/niklasvh/4NDb9/93/. The highlight function now requires the first variable to be an element from which to look. – Niklas Jun 05 '11 at 11:08
0

I know that the question is not about this relevant but this is what I was actually searching for.

If you need to Highlight SELECTED TEXT

Use the following principe: operate with Selection Range methods, like this


document.getSelection().getRangeAt(0).surroundContents(YOUR_WRAPPER_NODE) // Adds wrapper
document.getSelection().getRangeAt(0).insertNode(NEW_NODE) // Inserts a new node

That's it, I recomend you to study more about Range methods.

I was strugling with this and my searching requests were incorrect, so I decided to post it here for the case there will be people like me.

Sorry again for irelevant answer.

FrameMuse
  • 143
  • 1
  • 9
  • 1
    I believe that in your code snippet `YOUR_WRAPPER_NODE` and `NEW_NODE` are the same entity, so it would be a good idea to name them the same. Also, your solution doesn't work when the range contains partially selected elements. In that case, replace the call to `surroundContents` with `YOUR_WRAPPER_NODE.appendChild( document.getSelection().getRangeAt(0).extractContents())`. – aadcg Mar 17 '23 at 21:42
  • I just show methods that can be used, actually there is no connection between these lines, it's just example of methods. And yes it may not work for all cases, because again it's meant to be just an example. I just didn't want to make even more irelevant, breaking down the whole thing. Althought, thank you for sharing this knowledge ^_^ – FrameMuse Mar 19 '23 at 04:13