22

I have some text:

<p class="drag">Hello world, Attack on Titan season two!</p>

Currently, if a user wants to highlight a word/term with the cursor, they will click and drag, letter by letter.

I want this process to be quicker. For example, if the user starts to highlight At, it should auto highlight the rest of the word, Attack. So the empty space is the divider.

I am aware this is possible by dividing the words into divs, but I am hoping for a solution with pure text within one <p> tag.

Dave Newton
  • 158,873
  • 26
  • 254
  • 302
Henrik Petterson
  • 6,862
  • 20
  • 71
  • 155
  • Definitely doable without extra elements, have just created a working test locally - will spin up a jsfiddle ASAP – MDEV Jan 30 '16 at 15:46
  • 4
    explain the user how to use A) doubleclick followed by shift+click B) click followed by ctrl+shift+right (repeatable) – Pavel Gatnar Jan 30 '16 at 15:50
  • 2
    Is this the functionality you were after? https://jsfiddle.net/SmokeyPHP/vwf1ga7y/ - this only does it on completion though – MDEV Jan 30 '16 at 15:51
  • @SmokeyPHP Yes! That is the exact behavior I am looking for. Can you please post an answer with this solution, and more importantly, make it work if the user highlights the terms in a reversed order (from right to left). Also, if possible, it highlights the words live (not just on completion). – Henrik Petterson Jan 30 '16 at 15:59
  • @HenrikPetterson Yea I'm looking at the live selection now, but it appears more of a pain than I thought. Backwards selection should be easy enough, but not sure what I'm missing on the live selection update. – MDEV Jan 30 '16 at 16:01
  • @SmokeyPHP Ok. Please post an answer whenever you have something that works. The live selection part is not as important as the browser compability and that it works on mobile devices (touch screen). – Henrik Petterson Jan 30 '16 at 16:04
  • 3
    I'm just curious, following Pavel's question, what's preventing you from using double-click to select a single complete word? – dchayka Feb 02 '16 at 17:13
  • agree with @dchayka pardon me if I have missed anything. – Varshaan Feb 03 '16 at 12:56
  • 3
    @Varshaan Gentlemen, user experience: http://ux.stackexchange.com What that seems easy for you (double-click) must be explained to some users. Single click in our environment works better. – Henrik Petterson Feb 03 '16 at 12:59

6 Answers6

16

Using node.textContent we can find spaces and jump our selection to whole words without the need for creating new elements.

Mainly for my potential future use I've written this fairly modular, it also doesn't require a mouseup on the watched element, can deal with an element collection and also makes the selection changes if the user makes their selection using their keyboard.

var WordJumpSelection = (function() {
    var watchList = [];
    var WordJumpSelection = {
        stopWatching: function(elem) {
            var wlIdx = watchList.indexOf(elem);
            if(wlIdx > -1) watchList.splice(wlIdx,1);
        },
        watch: function(elem) {
            var elems = Array.prototype.slice.call(typeof elem.length === "number" ? elem : arguments);
            if(watchList.length === 0)
            {
                WordJumpSelection.init();
            }
            elems.forEach(function(elem) {
                if(watchList.indexOf(elem) === -1)
                {
                    watchList.push(elem);
                }
            });
        },
        init: function() {
            function handleSelectionChange() {
                if(watchList.length === 0) return;
                var selection = window.getSelection();
                var selDir = getSelectionDir(selection);
                var startNode,endNode,startPos,endPos;
                if(selDir === 1)
                {
                    startNode = selection.anchorNode;
                    endNode = selection.focusNode;
                    startPos = selection.anchorOffset;
                    endPos = selection.focusOffset;
                }
                else
                {
                    startNode = selection.focusNode;
                    endNode = selection.anchorNode;
                    startPos = selection.focusOffset;
                    endPos = selection.anchorOffset;
                }
                var rangeStart = textNodeIsWatched(startNode) ? roundSelectionIndex(startNode,0,startPos) : startPos-1;
                var rangeEnd = textNodeIsWatched(endNode) ? roundSelectionIndex(endNode,1,endPos) : endPos;
                var r = document.createRange();
                r.setStart(startNode,rangeStart+1)
                r.setEnd(endNode,rangeEnd)
                selection.removeAllRanges();
                selection.addRange(r);
            }
            document.documentElement.addEventListener('mouseup', handleSelectionChange);
            document.documentElement.addEventListener('keyup', function(e) {
                if(e.keyCode === 16)
                {
                    handleSelectionChange();
                }
            });
            WordJumpSelection.init = function(){};
        }
    };
    return WordJumpSelection;

    function getSelectionDir(sel) {
        var range = document.createRange();
        range.setStart(sel.anchorNode,sel.anchorOffset);
        range.setEnd(sel.focusNode,sel.focusOffset);
        if(range.startContainer !== sel.anchorNode || (sel.anchorNode === sel.focusNode && sel.focusOffset < sel.anchorOffset)) return -1;
        else return 1;
    }
    function roundSelectionIndex(textNode,nodeId,idx) {
        var isStart = nodeId === 0;
        var contents = textNode.textContent;
        var nearestSpaceIdx = -1;
        if(isStart)
        {
            nearestSpaceIdx = contents.lastIndexOf(' ',idx);
            if(nearestSpaceIdx === -1) nearestSpaceIdx = -1;
        }
        else
        {
            nearestSpaceIdx = contents.indexOf(' ',idx);
            if(nearestSpaceIdx === -1) nearestSpaceIdx = contents.length;
        }
        return nearestSpaceIdx;
    }
    function textNodeIsWatched(textNode) {
        return watchList.indexOf(textNode.parentElement) > -1;
    }
})();

An example jsFiddle

I am yet to test how this works on mobile, and haven't got it working live yet - but it might be a good start.

Update: Now selects word with a single click

MDEV
  • 10,730
  • 2
  • 33
  • 49
  • 2
    This is excellent! Thank you very much! I have one final request to make this complete. Can you adjust your code so that if the user clicks on a word, it will highlight the whole word. So to clarify, if the user clicks a word, it will be highlighted, if the user drags the cursor when highlighting, it will behave the same way that it is with your code. – Henrik Petterson Jan 31 '16 at 10:59
  • 1
    @HenrikPetterson Have updated it to select a word with a single click. Still trying to work out why I can't make it work live fully (I can make it update the start point live, but I think the end breaks because the user then moves their mouse and changes the selection back) – MDEV Jan 31 '16 at 13:44
  • 1
    The live feature is not necessary. There is however one bug on Firefox. Click on *Attack* to highlight it, then click on it again to drag *Attack on Titan*. Instead of creating a new click/drag event, it literally drags the word. You will see the issue when you test it on FF. Is there any fix for this behavior? It works fine on webkit browsers and should be the behavior. – Henrik Petterson Jan 31 '16 at 14:39
  • 1
    I'm not sure what you mean, Chrome, Firefox and IE all do the same thing for me, my expected behaviour is dragging a selection, not starting a new one. This could potentially be coded around, but isn't standard behaviour as far as I'm aware – MDEV Jan 31 '16 at 15:17
  • 2
    I'm testing this on latest version of FF on OSX and can see this behavior. Maybe you aren't doing it as I am. Please test this: 1. Click on *Attack*. 2. Then press the left-click and **hold in the middle of the word**, and then drag. So if it works as it should or if it literally drags the selected word... – Henrik Petterson Jan 31 '16 at 15:21
  • Right okay, the difference here must be OSX, I'm on Windows and the behaviour you're describing is normal and actually a feature I use frequently. Dragging a selection should drag the selected text as a clone, just as dragging an image does, at least in Windows browsers. My code doesn't specify the behaviour in either way, but it could potentially be coded around to start a new selection rather than drag the existing one, assuming that's the behaviour you're after? – MDEV Jan 31 '16 at 16:03
10

You can do that with pure JS using Range and selectionRange objects.

HTML:

<div id="selectable">
  <p>Hello world, <b>Attack on Titan</b> season two!</p>
  <p>Another paragraph with sample text.</p>
</div>
<div id="notSelectable">
  <p>The selection will behave normally on this div.</p>
</div>

JS:

(function(el){
    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);
            /* } */
        } else { 
           // Fallback for Internet Explorer 8 and earlier
           // (if you think it still is worth the effort of course)
        }

        // Stop Moz user select
        el.style.MozUserSelect = '-moz-none';
    });

    /* This part is added 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)
        }

        // Add Moz user select back
        el.style.MozUserSelect = 'text';
    });
})(document.getElementById('selectable'));

Please check the working example here.

UPDATES:

  • Whole word selection on click added
  • Fix for Firefox specific drag behavior added

Updated JSFiddle here.

Arman Ozak
  • 2,304
  • 11
  • 11
  • 2
    Thank you for the answer Arman, but it doesn't seem to work correctly. For example, with your cursor, select **Attack on Ti**, you will see that it doesn't select the entire term. Secondly, backwards selection doesn't work. Can you please update your answer with a fix to this? Thanks for your time regardless. – Henrik Petterson Jan 30 '16 at 16:06
  • 2
    Sorry, I made a mistake that would cause a misbehavior on child nodes (`` in this case). Fixed it now and updated the link for the fiddle as well. – Arman Ozak Jan 30 '16 at 16:14
  • 1
    This is lovely. Does this work on mobile (touch screen)? Can you please confirm. Thank you. – Henrik Petterson Jan 30 '16 at 16:15
  • 1
    The function bound to the event should work, but I don't think it is possible to trigger it properly. Selection on mobile browsers work quite differently. – Arman Ozak Jan 30 '16 at 16:52
  • This is in everyway exactly what I am looking for. I have one final request to make this complete. Can you adjust your code so that if the user clicks on a word, it will highlight the whole word. So to clarify, if the user clicks a word, it will be highlighted, if the user drags the cursor when highlighting, it will behave the same way that it is with your code. – Henrik Petterson Jan 31 '16 at 10:58
  • Remove the `if(selected.toString().length)` check and it will start selecting words on click too. – Arman Ozak Jan 31 '16 at 11:07
  • Can you update the jsfiddle to demonstrate this? I removed that check and it didn't work. Thank you very much. – Henrik Petterson Jan 31 '16 at 13:05
  • Excellent. For clarification, this code doesn't break the words into span tags? – Henrik Petterson Jan 31 '16 at 13:15
  • Thank you, works perfectly. There is however one bug on Firefox. Click on *Attack* to highlight it, then click on it again to drag *Attack on Titan*. Instead of creating a new click/drag event, it literally drags the word. You will see the issue when you test it on FF. Is there any fix for this behavior? It works fine on webkit browsers and should be the behavior. – Henrik Petterson Jan 31 '16 at 14:38
  • This is not a bug but a browser specific behavior. You can test it on the `div#notSelectable` which does not have the function applied on. Make a selection on "behave" or "normally" for example (even partial selections are ok) and try to initiate a drag. You will se it that the selection will be dragged. You can try the same on this very page (or any page for that matter) and see the same result. – Arman Ozak Jan 31 '16 at 16:08
  • @HenrikPetterson I added a fix for that behavior and updated the answer accordingly. – Arman Ozak Jan 31 '16 at 16:40
  • I am accepting this as the correct answer, thank you very much for everything. Does this work on IE 9/10/11? Do you have one of them to test it? – Henrik Petterson Jan 31 '16 at 17:17
  • I am on a Mac, so no I haven't, but according to the specifications it should. – Arman Ozak Jan 31 '16 at 17:19
  • Thank you for everything. I only have one final question. I want to target a class named "selectable" rather than an ID. I tried getElementsByClassName() but it didn't work. Can you please update your jsfiddle to demonstrate how I could target a class instead? – Henrik Petterson Feb 04 '16 at 16:45
  • Try document.getElementsByClassName('selectable')[0] – Arman Ozak Feb 04 '16 at 16:48
  • I tried this already, but it only seems to work with the first class of the page. I want to target all tags containing this class, see this: https://jsfiddle.net/ntg2ouby/2/ – Henrik Petterson Feb 04 '16 at 16:50
  • 2
    Then you should put it in a `for` loop. Updated fiddle [here](https://jsfiddle.net/ntg2ouby/4/). – Arman Ozak Feb 04 '16 at 16:55
  • Works great! The "selectable" class will be applying to tags via jQuery randomly after the page has been fully loaded. Will this script *listen* and target these classes even if they are applied after page load etc? – Henrik Petterson Feb 04 '16 at 17:00
  • Yes, it will. It will be available as soon as the DOM is ready. Make sure you include this code within a `jQuery(document).ready(function(e){ });` and it will work perfectly. Alternatively, you can put this code right before `

    ` (inline script or linked js file, at the end of the body) and it will work (no jQuery required).

    – Arman Ozak Feb 04 '16 at 17:05
  • 1
    Arman, I have posted a *follow-up* question to your outstanding answer here: http://stackoverflow.com/questions/35390994/if-a-word-is-highlighted-and-user-clicks-the-connecting-word-highlight-both -- I'll bounty that one with 50 points as well in case your JS wizardry is up for it. Thanks again for everything. – Henrik Petterson Feb 14 '16 at 11:05
  • This is one of the best answers I've seen on this platform, great stuff. Only problem seem to be that it is throwing an error. Check the console when you select words and you will see it yourself (using Firefox). Any fix on this? – Gary Woods Apr 25 '16 at 11:34
9

Concepts

To select each word, there are something you must keep in mind first:

  1. textNode is a single sting contains all the words, you won't be able to select each "word", since it's not a DOM node.

  2. There is no specific event triggered in browser when you "drag and select" a word. However, when you drag & select, there are 2 events get fired: mouseover is triggered when you move your mouse, click is triggered when you release your mouse button. (This is true even in Mac's touchpad).

  3. There are different implementation on "highlight" when you select a word.

Steps

Based on the concepts, you have to do the following steps sequentially to achieve your goal:

  1. Get the words in paragraph and wrapped them with tag (e.g. <span>) for DOM selection
  2. When the click event is triggered (which indicate your select has ended), highlight the word you just select.

The implementation would be something likes this (with jQuery). And you may see the live demo here:

$(function() {

  // 1. When mouseover the paragraph, wrapped each word with <span>
  $('p').one('mouseover', function(event) {
    $('p').html(function(index, text) {
      var wordsArray = text.split(' ');

      var wrappedArray = wordsArray.map(function(val, index) {
        val = '<span class="chunk-' + index + '">' + val + '</span>';
        return val;
      });

      var wrappedString = wrappedArray.join(' ');

      // 2. Replace the paragraph with wrapped text
      $(this).html(wrappedString);

      // 3. When the word is select, highlight the word
      $(this).children('span').on('click', function() {
        var selector = '.' + $(this).attr('class');
        SelectText(selector);
      });
    });
  });
});


function SelectText(element) {
  var doc = document,
    text = doc.querySelector(element),
    range, selection;
  if (doc.body.createTextRange) {
    range = document.body.createTextRange();
    range.moveToElementText(text);
    range.select();
  } else if (window.getSelection) {
    selection = window.getSelection();
    range = document.createRange();
    range.selectNodeContents(text);
    selection.removeAllRanges();
    selection.addRange(range);
  }
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Autem amet suscipit incidunt placeat dicta iure, perspiciatis libero nobis dolore, temporibus et! Quae fugiat necessitatibus ut, molestias aut. Sequi rerum earum facilis voluptates ratione architecto
  officia quod aut unde voluptas? Dignissimos ducimus exercitationem perspiciatis nam numquam minima accusamus quod necessitatibus amet illo vel vero placeat voluptate eos iste ratione veniam quisquam atque non voluptatum sint hic sed, suscipit. Doloremque
  officiis rerum sunt delectus unde odit eos quod earum aspernatur, tempora neque modi tempore minima maiores fuga eaque dolore quos minus veritatis aliquid, vel suscipit dolores. Voluptatem eius obcaecati, laborum ipsa a!</p>

SelectText function should attribute to @Jason in this post on SO: Selecting text in an element: akin to highlighting with your mouse

Community
  • 1
  • 1
kavare
  • 1,786
  • 2
  • 17
  • 26
  • 2
    Thank you for the answer, however, if a user wants to select an entire phrase like *Attack on Titan*, it doesn't work... only works if user clicks on word, not drags (which is what I am aiming for) – Henrik Petterson Jan 30 '16 at 14:37
  • 1
    What do you mean? You can drag and select, no need to click in this example. Check the live demo. – kavare Jan 30 '16 at 14:39
  • 1
    If you are trying to select multiple words, then it's a far fetch, since it's hard to defined where will your decided words ended. If you do, wrap that word in something (like a quotation mark), the only thing you need to change here is to replace the split from `space` to a Regex which matches whatever you want. – kavare Jan 30 '16 at 14:42
  • Yes, I'm aiming to allow the user to select multiple words. Is it possible to make the space as the divider? So for example, if the user selects *Attack on Ti*, it should autoselect *Attack on Titan* so the user doesn't need to drag the cursor all the way to the end of the word. Hope that makes sense. – Henrik Petterson Jan 30 '16 at 14:46
  • Cannot understand this..... If a user selects Attack on Ti, then Attack on Ti is already selected, correct? If the space is the divider, Attack on Ti would be divided as ['Attack', 'on', 'Ti']. – kavare Jan 30 '16 at 14:49
  • 2
    Sorry about the lack of clarification. Let me explain this further. The script should understand that the user is "in the middle" of selecting a phrase. It shouldn't be possible to select a part of a word, as soon as the user drags over a new (connecting) word, it should highlight the entire word. So if the user selects *Attack on Tit*, it should auto select *Attack on Titan*. – Henrik Petterson Jan 30 '16 at 15:01
  • 2
    @kavare - essentially, he is looking for this code to loop and start over if a user, while dragging the cursor, gets into another word. Take the phrase "Attack on Titan". If a begin to drag over"At", my selection should look like "`Attack` on Titan". Once I continue to drag toward the right and reach " o", then the highlighting should know I want the word on and jump to the following format "`Attack on` Titan". That is my understanding of the issue. Continuing to hold and drag, once I hit " T", formatting should look like "`Attack on Titan`". – Tommy Jan 30 '16 at 15:58
7

This text is a text node, and text nodes simply don't fire most events. But they can fire DOM mutation events, for example DOMCharacterDataModified, which is used to detect change to a text node's text:

var textNode = document.getElementsByClassName("drag")[0].firstChild;

textNode.addEventListener("DOMCharacterDataModified", function(e) {
    console.log("Text changed from '" + e.prevValue + "' to '" + evt.newValue +"'");
}, false);

However, the text in <p class="drag">Hello world, Attack on Titan season two!</p> is a single text node and you need every word to be a separate node.

The only solution I see is to put every word in a span tag. You can't do this with pure text.

Edit

Here's an example how to do this with span tags (I'm using jQuery here just to reduce the code amount, it's not necessary):

$(function() {
    $('.drag').on('click', 'span', function() {
        var range;
        if (document.selection) {
            range = document.body.createTextRange();
            range.moveToElementText($(this)[0]);
            range.select();
        } 
        else if (window.getSelection) {
            range = document.createRange();
            range.selectNode($(this)[0]);
            window.getSelection().addRange(range);
        }
    });  
});

Here's an example on JS Bin

Update

I edited the code snippet so the selection behaves like you asked (currently it's only works for selections from left to right, not reverse selections):

$(function(){

  var range = document.createRange();
  var selectionMode = false;

  $(document).on('mouseup', function() {
    selectionMode = false;
  })
  .on('mousedown', '.drag', function(e) {
    selectionMode = true;
  })
  .on('dragstart', '.drag span', function(e) {
    return false;
  });

  $('.drag').on('mousedown', 'span', function() {
    range.setStartBefore($(this)[0]);
    range.setEndAfter($(this)[0]);
    window.getSelection().addRange(range);
  })
  .on('mousemove', 'span', function() {
    if (!selectionMode) {
      return;
    }
    range.setEndAfter($(this)[0]);
    window.getSelection().addRange(range);
  })
  .on('mouseup', 'span', function() {
    setTimeout(function(){
      window.getSelection().addRange(range);
    }, 1);
  });  

});

You can read more about HTML Range API here: https://developer.mozilla.org/en-US/docs/Web/API/Range

Konstantine Kalbazov
  • 2,643
  • 26
  • 29
  • It looks like ideone.com is for server-side scripting only, so I created an example on JS Bin https://jsbin.com/cibimazetu/edit?js,output – Konstantine Kalbazov Jan 30 '16 at 13:40
  • 2
    That's very interesting, however, if I user wants to select an entire phrase like *Attack on Titan*, it doesn't work... only works if user clicks on word, not drags (which is what I am aiming for). – Henrik Petterson Jan 30 '16 at 13:42
  • 1
    I changed the code on JS Bin. Just simply change `$('.drag').on('click', 'span', function() {` to `$(document).on('click', '.drag', function() {` And you don't need to wrap words with `span` tags in this case. – Konstantine Kalbazov Jan 30 '16 at 13:47
  • Not sure if you edited with the latest version of the jsbin, but by clicking it, it highlighted the entire p tag. And dragging will just behave like normal. I'm using firefox. – Henrik Petterson Jan 30 '16 at 13:57
  • I did'n understand you well, I thought you meant you wanna select the entire phrase. I'll think about dragging ) – Konstantine Kalbazov Jan 30 '16 at 14:00
  • 2
    Ah, sorry for the lack of clarification. Basically, image if the user wants to select the term *Attack on Titan*. I want the highlighting to go quicker and smarter. By default, the user must select the very beginning of the first word and drag all the way to the end of the last word in the term. – Henrik Petterson Jan 30 '16 at 14:02
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/102083/discussion-between-kote-and-henrik-petterson). – Konstantine Kalbazov Jan 30 '16 at 15:16
3

So you are going to have to deal with text ranges and such. I've dealt with this, and it's extremely painful, especially if you have DOM contents like:

<p>New season of <span class="redtext">Attack on Titan!</span></p>

i.e. text nodes mixed with other DOM elements, such as a span in this case. With this in mind, I'd like to highly recommend the library rangy.js: https://github.com/timdown/rangy

It's saved me several days of headache when I was making a hashtag highlighting system.

sg.cc
  • 1,726
  • 19
  • 41
-4

You cannot set events to Text, but can set events to Html Elements. Put each word inside a div element and add an event onmouseover that changes div to a new highlighted state using css.

Steps:

  1. Use split to get words into array.
  2. Iterate words and put it into a div.
  3. Iterate divs and set it an event that's change the div to a css class .highlight.

That's it.

Serginho
  • 7,291
  • 2
  • 27
  • 52
  • 2
    Are you certain this is not possible with text? If not, can you please edit your answer with some code example of how your approach would work? – Henrik Petterson Jan 23 '16 at 12:51
  • 1
    That's enough info, thank you. The question is essentially if this is possible with plain text, rather than placing words into divs. – Henrik Petterson Jan 23 '16 at 13:00
  • No, that's not possible to add listeners to text in html. Think that if you want to use addEventListener you have to select an html element. If you believe I help you, please mark as best answer and press +1. – Serginho Jan 23 '16 at 22:19