7

I recently posted a question asking for a way to highlight words smarter by:

  • Single-click highlights the whole word (default behavior is double-click).

  • Click-drag will hightlight full words/terms only.

Beautiful solution was posted by Arman.

jsFiddle for testing.

My aim with this question is to allow the user to single-click two or more connecting words and highlight them (extend the range of the highlight).

To demonstrate. If world, is selected by the cursor:

Hello world, lorem ipsum attack on titan.

And user clicks on lorem, it should select both words like this:

Hello world, lorem ipsum attack on titan.

Same behavior if user clicks Hello.

So it only extends the highlight if the word is connecting. Example, if worlds, is selected, and user clicks on ipsum, it should just select ipsum.

What's the approach to extend the highlight reach?

Code in jsFiddle is:

jQuery(document).ready(function(e){

    (function(els){
        for(var i=0;i<els.length;i++){
            var el = els[i];
            el.addEventListener('mouseup',function(evt){
                if (document.createRange) { // Works on all browsers, including IE 9+
                    var selected = window.getSelection();
                    /* if(selected.toString().length){ */
                    var d = document,
                        nA = selected.anchorNode,
                        oA = selected.anchorOffset,
                        nF = selected.focusNode,
                        oF = selected.focusOffset,
                        range = d.createRange();

                    range.setStart(nA,oA);
                    range.setEnd(nF,oF);

                    // Check if direction of selection is right to left
                    if(range.startContainer !== nA || (nA === nF && oF < oA)){
                        range.setStart(nF,oF);
                        range.setEnd(nA,oA);
                    }

                    // Extend range to the next space or end of node
                    while(range.endOffset < range.endContainer.textContent.length && !/\s$/.test(range.toString())){
                        range.setEnd(range.endContainer, range.endOffset + 1);
                    }
                    // Extend range to the previous space or start of node
                    while(range.startOffset > 0 && !/^\s/.test(range.toString())){
                        range.setStart(range.startContainer, range.startOffset - 1);
                    }

                    // Remove spaces
                    if(/\s$/.test(range.toString()) && range.endOffset > 0)
                        range.setEnd(range.endContainer, range.endOffset - 1);
                    if(/^\s/.test(range.toString()))
                        range.setStart(range.startContainer, range.startOffset + 1);

                    // Assign range to selection
                    selected.addRange(range);

                    el.style.MozUserSelect = '-moz-none';
                    /* } */
                } else {
                    // Fallback for Internet Explorer 8 and earlier
                    // (if you think it still is worth the effort of course)
                }
            });

            /* This part is necessary to eliminate a FF specific dragging behavior */
            el.addEventListener('mousedown',function(){
                if (window.getSelection) {  // Works on all browsers, including IE 9+
                    var selection = window.getSelection ();
                    selection.collapse (selection.anchorNode, selection.anchorOffset);
                } else {
                    // Fallback for Internet Explorer 8 and earlier
                    // (if you think it still is worth the effort of course)
                }
                el.style.MozUserSelect = 'text';
            });
        }
    })(document.getElementsByClassName('taggable'));

});

HTML:

<p class="taggable">
   Hello world, lorem ipsum attack on titan.
</p>

<p>
   JS doesn't affect this text. 
</p>

Bounty info

Rewarding the existing answer because it's profoundly useful. No need to post more solutions as this one is as complete as it gets.

Community
  • 1
  • 1
Henrik Petterson
  • 6,862
  • 20
  • 71
  • 155

1 Answers1

3

 

UPGRADE

Ok, I am putting this at top, because it is a major update and, I believe, can even be considered as an upgrade on the previous function.

The request was to make the previous function work in reverse, i.e. when a highlighted word is clicked again, it would be removed from the total selection.

The challenge was that when a highlighted word at the edge of <p> and </p> tags or the edge of <b> and </b> tags inside the paragraphs was clicked, the startContainer or endContainer of the range had to be carried into or out of the current element they were positioned and the startOffset or endOffset had to be reset as well. I am not sure if this has been a clear expression of the problem, but, in brief, due to the way Range objects work, the words closest to HTML tags proved to be quite a challenge.

The Solution was to introduce a few new regex tests, several if checks, and a local function for finding the next/previous sibling. During the process, I have also fixed a few things which had escaped my attention before. The new function is below and the updated fiddle is here.

 

(function(el){
  // variable declaration for previous range info
  // and function for finding the sibling
    var prevRangeInfo = {},
    findSibling = function(thisNode, direction){
      // get the child node list of the parent node
      var childNodeList = thisNode.parentNode.childNodes,
        children = [];

        // convert the child node list to an array
        for(var i=0, l=childNodeList.length; i<l; i++) children.push(childNodeList[i]);

        return children[children.indexOf(thisNode) + direction];
    };

    el.addEventListener('mouseup',function(evt){
        if (document.createRange) { // Works on all browsers, including IE 9+

            var selected = window.getSelection();
      // Removing the following line from comments will make the function drag-only
            /* if(selected.toString().length){ */
                var d = document,
                    nA = selected.anchorNode,
                    oA = selected.anchorOffset,
                    nF = selected.focusNode,
                    oF = selected.focusOffset,
                    range = d.createRange(),
          rangeLength = 0;

                range.setStart(nA,oA);
                range.setEnd(nF,oF);

                // Check if direction of selection is right to left
                if(range.startContainer !== nA || (nA === nF && oF < oA)){
                    range.setStart(nF,oF);
                    range.setEnd(nA,oA);
                }

                // Extend range to the next space or end of node
                while(range.endOffset < range.endContainer.textContent.length && !/\s$/.test(range.toString())){
                    range.setEnd(range.endContainer, range.endOffset + 1);
                }
                // Extend range to the previous space or start of node
                while(range.startOffset > 0 && !/^\s/.test(range.toString())){
                    range.setStart(range.startContainer, range.startOffset - 1);
                }

                // Remove spaces
                if(/\s$/.test(range.toString()) && range.endOffset > 0)
                    range.setEnd(range.endContainer, range.endOffset - 1);
                if(/^\s/.test(range.toString()))
                    range.setStart(range.startContainer, range.startOffset + 1);

        // Store the length of the range
        rangeLength = range.toString().length;

        // Check if another range was previously selected
        if(prevRangeInfo.startContainer && nA === nF && oA === oF){
            var rangeTryContain = d.createRange(),
            rangeTryLeft = d.createRange(),
            rangeTryRight = d.createRange(),
            nAp = prevRangeInfo.startContainer;
            oAp = prevRangeInfo.startOffset;
            nFp = prevRangeInfo.endContainer;
            oFp = prevRangeInfo.endOffset;

          rangeTryContain.setStart(nAp, oAp);
          rangeTryContain.setEnd(nFp, oFp);
          rangeTryLeft.setStart(nFp, oFp-1);
          rangeTryLeft.setEnd(range.endContainer, range.endOffset);
          rangeTryRight.setStart(range.startContainer, range.startOffset);
          rangeTryRight.setEnd(nAp, oAp+1);

          // Store range boundary comparisons
          // & inner nodes close to the range boundary --> stores null if none
          var compareStartPoints = range.compareBoundaryPoints(0, rangeTryContain) === 0,
            compareEndPoints = range.compareBoundaryPoints(2, rangeTryContain) === 0,
            leftInnerNode = range.endContainer.previousSibling,
            rightInnerNode = range.startContainer.nextSibling;

          // Do nothing if clicked on the right end of a word
          if(range.toString().length < 1){
            range.setStart(nAp,oAp);
            range.setEnd(nFp,oFp);
          }

          // Collapse the range if clicked on last highlighted word
          else if(compareStartPoints && compareEndPoints)
            range.collapse();

          // Remove a highlighted word from left side if clicked on
          // This part is quite tricky!
          else if(compareStartPoints){
            range.setEnd(nFp,oFp);

            if(range.startOffset + rangeLength + 1 >= range.startContainer.length){
              if(rightInnerNode)
                // there is a right inner node, set its start point as range start
                range.setStart(rightInnerNode.firstChild, 0);

              else {
                // there is no right inner node
                // there must be a text node on the right side of the clicked word

                // set start of the next text node as start point of the range
                var rightTextNode = findSibling(range.startContainer.parentNode, 1),
                    rightTextContent = rightTextNode.textContent,
                    level=1;

                // if beginning of paragraph, find the first child of the paragraph
                if(/^(?:\r\n|[\r\n])|\s{2,}$/.test(rightTextContent)){
                    rightTextNode = findSibling(rightTextNode, 1).firstChild;
                  level--;
                }

                range.setStart(rightTextNode, level);

              }
            }
            else
              range.setStart(range.startContainer, range.startOffset + rangeLength + 1);
          }

          // Remove a hightlighted word from right side if clicked on
          // This part is also tricky!
          else if (compareEndPoints){
            range.setStart(nAp,oAp);

            if(range.endOffset - rangeLength - 1 <= 0){
              if(leftInnerNode)
                // there is a right inner node, set its start point as range start
                range.setEnd(leftInnerNode.lastChild, leftInnerNode.lastChild.textContent.length);

              else {
                // there is no left inner node
                // there must be a text node on the left side of the clicked word

                // set start of the previous text node as start point of the range
                var leftTextNode = findSibling(range.endContainer.parentNode, -1),
                    leftTextContent = leftTextNode.textContent,
                    level = 1;

                // if end of paragraph, find the last child of the paragraph
                if(/^(?:\r\n|[\r\n])|\s{2,}$/.test(leftTextContent)){
                    leftTextNode = findSibling(leftTextNode, -1).lastChild;
                  level--;
                }

                range.setEnd(leftTextNode, leftTextNode.length - level);
              }
            }
            else
              range.setEnd(range.endContainer, range.endOffset - rangeLength - 1);
          }

          // Add previously selected range if adjacent
          // Upgraded to include previous/next word even in a different paragraph
          else if(/^[^\s]*((?:\r\n|[\r\n])|\s{1,})[^\s]*$/.test(rangeTryLeft.toString()))
            range.setStart(nAp,oAp);
          else if(/^[^\s]*((?:\r\n|[\r\n])|\s{1,})[^\s]*$/.test(rangeTryRight.toString()))
            range.setEnd(nFp,oFp);

          // Detach the range objects we are done with, clear memory
          rangeTryContain.detach();
          rangeTryRight.detach();
          rangeTryLeft.detach();
        }

        // Save the current range --> not the whole Range object but what is neccessary
        prevRangeInfo = {
            startContainer: range.startContainer,
          startOffset: range.startOffset,
          endContainer: range.endContainer,
          endOffset: range.endOffset
        };

        // Clear the saved range info if clicked on last highlighted word
        if(compareStartPoints && compareEndPoints)
          prevRangeInfo = {};

        // Remove all ranges from selection --> necessary due to potential removals
        selected.removeAllRanges();

                // Assign the current range as selection
                selected.addRange(range);

        // Detach the range object we are done with, clear memory
        range.detach();

        el.style.MozUserSelect = '-moz-none';

      // Removing the following line from comments will make the function drag-only
            /* } */

        } else { 
           // Fallback for Internet Explorer 8 and earlier
           // (if you think it still is worth the effort of course)
        }
    });

  /* This part is necessary to eliminate a FF specific dragging behavior */
  el.addEventListener('mousedown',function(e){
    if (window.getSelection) {  // Works on all browsers, including IE 9+
         var selection = window.getSelection ();
       selection.collapse (selection.anchorNode, selection.anchorOffset);
    } else {
       // Fallback for Internet Explorer 8 and earlier
           // (if you think it still is worth the effort of course)
    }
    el.style.MozUserSelect = 'text';
  });
})(document.getElementById('selectable'));

 


 

BEFORE UPGRADE

Storing the last range in an object and checking if the previously selected range is adjacent to the new range every time a new selection is made, does the job:

(function(el){
    var prevRangeInfo = {};
    el.addEventListener('mouseup',function(evt){
        if (document.createRange) { // Works on all browsers, including IE 9+

            var selected = window.getSelection();
            /* if(selected.toString().length){ */
                var d = document,
                    nA = selected.anchorNode,
                    oA = selected.anchorOffset,
                    nF = selected.focusNode,
                    oF = selected.focusOffset,
                    range = d.createRange();

                range.setStart(nA,oA);
                range.setEnd(nF,oF);

                // Check if direction of selection is right to left
                if(range.startContainer !== nA || (nA === nF && oF < oA)){
                    range.setStart(nF,oF);
                    range.setEnd(nA,oA);
                }

                // Extend range to the next space or end of node
                while(range.endOffset < range.endContainer.textContent.length && !/\s$/.test(range.toString())){
                    range.setEnd(range.endContainer, range.endOffset + 1);
                }
                // Extend range to the previous space or start of node
                while(range.startOffset > 0 && !/^\s/.test(range.toString())){
                    range.setStart(range.startContainer, range.startOffset - 1);
                }

                // Remove spaces
                if(/\s$/.test(range.toString()) && range.endOffset > 0)
                    range.setEnd(range.endContainer, range.endOffset - 1);
                if(/^\s/.test(range.toString()))
                    range.setStart(range.startContainer, range.startOffset + 1);

        // Check if another range was previously selected
        if(prevRangeInfo.startContainer){
            var rangeTryLeft = d.createRange(),
            rangeTryRight = d.createRange(),
            nAp = prevRangeInfo.startContainer;
            oAp = prevRangeInfo.startOffset;
            nFp = prevRangeInfo.endContainer;
            oFp = prevRangeInfo.endOffset;
          rangeTryLeft.setStart(nFp,oFp-1);
          rangeTryLeft.setEnd(range.endContainer,range.endOffset);
          rangeTryRight.setStart(range.startContainer,range.startOffset);
          rangeTryRight.setEnd(nAp,oAp+1);

          // Add previously selected range if adjacent
          if(/^[^\s]*\s{1}[^\s]*$/.test(rangeTryLeft.toString())) range.setStart(nAp,oAp);
          else if(/^[^\s]*\s{1}[^\s]*$/.test(rangeTryRight.toString())) range.setEnd(nFp,oFp);
        }

        // Save the current range
        prevRangeInfo = {
            startContainer: range.startContainer,
          startOffset: range.startOffset,
          endContainer: range.endContainer,
          endOffset: range.endOffset
        };

                // Assign range to selection
                selected.addRange(range);

        el.style.MozUserSelect = '-moz-none';
            /* } */
        } else { 
           // Fallback for Internet Explorer 8 and earlier
           // (if you think it still is worth the effort of course)
        }
    });

  /* This part is necessary to eliminate a FF specific dragging behavior */
  el.addEventListener('mousedown',function(e){
    if (window.getSelection) {  // Works on all browsers, including IE 9+
         var selection = window.getSelection ();
       selection.collapse (selection.anchorNode, selection.anchorOffset);
    } else {
       // Fallback for Internet Explorer 8 and earlier
           // (if you think it still is worth the effort of course)
    }
    el.style.MozUserSelect = 'text';
  });
})(document.getElementById('selectable'));

JS Fiddle here.

Update (was done before upgrade):

If you want to this feature to be effective when clicking but not dragging, all you have to do is to change the if(prevRangeInfo.startContainer) condition as follows:

if(prevRangeInfo.startContainer && nA === nF && oA === oF){
    // rest of the code is the same...

The updated JS Fiddle is here.

Arman Ozak
  • 2,304
  • 11
  • 11
  • This is amazing Arman! One issue though. Is it possible to ignore the dragging when connecting? So if the user drags with the mouse, it should not take consideration of a previously highlighted word/term. It should only connect *if the user single-clicks*. I hope I explained that correctly. – Henrik Petterson Feb 14 '16 at 20:01
  • I am testing this on my local environment, targetting a class instead of an ID but it doesn't work. Is it because it is not in a for loop? See this jsfiddle: https://jsfiddle.net/1tvoa0um/6/ – Henrik Petterson Feb 15 '16 at 12:38
  • 1
    Yes. If you pass a collection instead of a single element to this function, you need to put the code inside the function (not the function itself) in a loop. – Arman Ozak Feb 15 '16 at 12:44
  • I just gave you the incorrect jsfiddle. My apologies. Can you kindly demonstrate how to pass it in a collection as you state: https://jsfiddle.net/1tvoa0um/7/ – Henrik Petterson Feb 15 '16 at 12:45
  • 1
    The error was not caused by the function itself, but was caused by the `jQuery('document').ready();` function you used as a wrapper. Actually, there is nothing wrong with how you use `jQuery('document').ready();`, but JS Fiddle needs jQuery library to be added in the fiddle via the cog icon next to JAVASCRIPT title. [Here](https://jsfiddle.net/1tvoa0um/10/) is the version where your code is kept as is while the jQuery library is added and [here](https://jsfiddle.net/1tvoa0um/11/) is the version where `var prevRangeInfo = {};` declaration is taken out of the loop, because it is unneccessary. – Arman Ozak Feb 15 '16 at 19:37
  • Right, I completely forget that this was a pure javascript approach and didn't check if jQuery was called. Makes perfect sense. Can you please elaborate why the prevRangeInfo declaration is taken out of the loop? I will open a bounty tomorrow and reward this answer. Thank you so much again for sharing your skills. You're talented, specifically in how you explain each adjustment that you make, which is not common practice here. – Henrik Petterson Feb 15 '16 at 20:27
  • 1
    The `prevRangeInfo` declaration is taken out of the loop, because there will always be only one stored previous range and declaring a single blank object before the loop will do the trick. Can you include it in the loop? Yes, but that is additional workload on the JavaScript engine for no reason, so as a principle, you'd better avoid it. I am glad I have been able to help. Take care. – Arman Ozak Feb 15 '16 at 20:32
  • Absolutely, that makes perfect sense. Thanks again for everything. As for completion, is it easy to adjust the code so that it *removes* a *highlighted* word if it is **single-clicked**? So for example, if **Attack on Titan** is selected and user single-clicks on **Titan**, it only highlights **Attack on**... if this is too much of an adjustment, don't worry about it at all. – Henrik Petterson Feb 15 '16 at 21:46
  • ^ I want to know this too! GREAT answer/question. Very useful function. – Gary Woods Feb 15 '16 at 22:13
  • 1
    I hope this was what you were looking for. :) – Arman Ozak Feb 16 '16 at 03:05
  • Profound. Thank you so much! Works great! I have bounty this 200 which I will reward in a few days! – Henrik Petterson Feb 16 '16 at 13:22
  • So far, we have tested this on most main browsers, including some ones on mobile like the android browser or FF on android, works great! However, there are problems iOS safari browser, or any browser on iOS (iphone/ipad...). The actual code works on iOS, I can confirm this via the console. But you *can't* see the text being highlighted. So the user doesn't essentially know what text they have highlighted. You'll know what I mean if you test it yourself. Not sure if you know of an easy fix because it seems to be the default behavior of iOS :'( – Henrik Petterson Feb 16 '16 at 14:08
  • 1
    I am afraid this is far beyond an easy fix Henrik. The major issue here is the infamous 300ms delay on touch-enabled browsers as described [here](https://developers.google.com/web/updates/2013/12/300ms-tap-delay-gone-away) & [here](http://developer.telerik.com/featured/300-ms-click-delay-ios-8/). Hammer.js, FastClick and others are not made for selection based apps, so I'd be surprised if they work. jQuery mobile also won't work; its virtual mouse events won't deliver the `selectionRange` needed. AFAIK, jQuery team is cooperating with Hammer.js team to improve their touch events. Sorry. – Arman Ozak Feb 16 '16 at 18:19
  • @ArmanOzak Not a problem, thanks for clarifying! Maybe one option is to go with an *abstract* solution, like visualize it being highlighted by placing a div underneath the 'selectable' div and when user highlights, it'll output it on the div underneath which has styling. It will look like user has highlighted text. We have code for capturing the highlight and outputting it, test to highlight some words here: https://jsfiddle.net/5bk3j7bh/ -- maybe another way is to change the opacity of the words that aren't highlighted. Nonetheless, if you ever have a solution for this, please post a reply! – Henrik Petterson Feb 17 '16 at 12:22
  • 1
    Finally solved it by placing an identical div behind it and simulating a highlight by wrapping text into span tags and changing background color and so on. Probably the most mental piece of code. Thanks again for everything Arman. This is amazing stuff! – Henrik Petterson Feb 21 '16 at 22:38
  • Wow, that sounds mental indeed. It's good to know you gound a solution though. It was a pleasure working on the question and I'm glad I have been able to help. Take care. – Arman Ozak Feb 21 '16 at 22:49