Make text “more” selectable

Make text “more” selectable

I have text in a

tag:

Hello world… and goodbye mind A B!

How do I increase the area in which the text is selectable? I know I can increase the font-size and that would increase the area which is selectable, but is there a better way?
To clarify this question. For example, on mobile screens, I find it difficult to highlight words that are one letter like i, but if the hit detection would be on a wider area, it would be a lot easier to select it.
How to do it? A mind-teasing puzzle.
Bounty info
Looking for a working cross browser solution. Please read the question thoroughly and the comments before posting an answer to avoid confusion. User @mmm posted a question that’s quite close, but in his approach, while the

tag is has a wider hit detection (perfect!), it auto-selects upon click. I need the user to interact with the

tag just like we do with normal text based

tags… however with a larger hit detector.
EDIT
Further clarification. As an example, the selection area for a comment to this very question is this large:

You can find this comment below. Hover your cursor over it until the cursor gets changed to cursor:text. That’s the default selection area.
But my aim is to extend it to a larger area, like this:

Solutions/Answers:

Solution 1:

From my test it works on the iphone as well as ff and chrome – if someone can test on android I’ll appreciate feedback!

The border obviously can be removed.

This code uses code from this answer (part of the SelectText() function):
Selecting text in an element (akin to highlighting with your mouse)

Fiddle

Code:

function extendSelection() {
    var extendBy = arguments.length <= 0 || arguments[0] === undefined ? 15 : arguments[0];

    var extended = document.getElementsByClassName('extendedSelection');
    [].slice.call(extended).forEach(function (v) {
        var bounds = v.getBoundingClientRect();
        var x = bounds.left;
        var r = textWidth(v.innerHTML, ''+ css(v, 'font-weight') +' ' + css(v, 'font-size') + ' ' + css(v, 'font-family') );
        var y = bounds.top;
        var w = bounds.width;
        var h = bounds.height;
        var element = document.createElement('div');
        element.style.position = 'absolute';
        element.style.height = h + extendBy + 'px';
        element.style.width = r + extendBy + 'px';
        element.style.left = x - extendBy / 2 + 'px';
        element.style.top = y - extendBy / 2 + 'px';
        element.style.border = '1px dotted black';
        document.body.appendChild(element);
        element.addEventListener('click', function (e) {
            SelectText(v);
        });
        element.addEventListener('touchend', function (e) {
            SelectText(v);
        });
    });
}

function css(element, property) {
    return window.getComputedStyle(element, null).getPropertyValue(property);
}

function textWidth(text, font) {
    var el = textWidth.canvas || (textWidth.canvas = document.createElement("canvas"));
    var draw = el.getContext("2d");
    draw.font = font;
    var m = draw.measureText(text);
    return m.width;
};

function SelectText(element) {
    var doc = document,
        text = 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);
        selection.setSelectionRange(0, element.value.length)
    }
}

extendSelection();

Solution 2:

You could add padding around paragraphs as someone already suggested but also use negative margin of the same value to prevent it from affecting layout.

Related:  What is the meaning of the 'g' flag in regular expressions?

Here is DEMO (double-clicking or long-tapping anywhere inside gray area should select text)

Relevant code:

HTML:

<p>normal paragraph</p>
<hr />
<p class="fat-fingers">fat fingers paragraph</p>

CSS:

p {
    //resetting default browser styles for brevity
    //otherwise adjust negative margin value so it's == default margin - padding
    margin: 0;
}

.fat-fingers {
    padding: 10px;
    margin: -10px;
}

note: I didn’t test case of two areas overlapping but I assume that the one with higher stacking order wins.

Solution 3:

I’m assuming the scenario where you have a body of text, and inside that body of text is a fairly important or relevant piece of information to an end user and you would like them to be able to easily highlight and copy the information.

This would be considered as a last option if no other solution was found,

<p class="surroundingText"> BLAH BLAH BLAH  <span class="importantText"> This is the information you would like users to be able to highlight </span> BLAH BLAH BLAH BLAH ETC ETC ETC </p>

If you wrap the text around it in separate paragraph tags and give them a class then set the following in CSS:

.surroundingText {

  -webkit-user-select: none;  /* Chrome all / Safari all */
  -moz-user-select: none;     /* Firefox all */
  -ms-user-select: none;      /* IE 10+ */
  user-select: none;         
}

.importantText {

        -webkit-user-select: all;  /* Chrome all / Safari all */
      -moz-user-select: all;     /* Firefox all */
      -ms-user-select: all;      /* IE 10+ */
      user-select: all;
    }

So the end result is only the text between the span tag is able to be selected.

Related:  How to move a marker in Google Maps API

Solution 4:

Exaggerated for effect but what about:

<head>
    <style>   
    p {
        letter-spacing: 2px;
        line-height: 3em;
        word-spacing: 1.5em;
        vertical-align: middle;
    }
    </style>
</head>
<body >

    <p>this is a line.</p>
    <p>this is a line.</p>
    <p>this is a line.</p>
    <p>this is a line.</p>
    <p>this is a line.</p>
    <p>this is a line.</p>
    <p>this is a line.</p>
</body>

Solution 5:

One approach is to increase the line-height of the <p> element. On a mobile device, you can better select a text fragment, because of the larger spacing between the lines. If you define the values in em, the spacing is always relative to the font-size.

A second approach is to increase the word-spacing of the text element to a level which is still acceptable. I would recommend a maximal value of of 0.2em.

HTML:

<p class="extendedSelection">Extended Selection</p>

CSS:

p.extendedSelection {
   line-height: 2em;
   word-spacing: 0.2em;
}

JSFiddle here

If those two approaches are not good enough, you could of course create an absolute positioned element for each word, which overlays the text element, but has an opacity of 0. The text inside of this element should be equal to the text behind but with a larger font-size. This approach has several drawbacks: You need to calculate the positions of every word and duplicate the text content. It is a rather large calculation for just a little effect.

Solution 6:

Here is one approach that can be considered if you are ok to have some extra clicks

  1. user needs to first click somewhere around the text that s/he wants to select.
  2. based on the click position we find out the word that was clicked
  3. now we take the previous word, current (clicked) word and next word and show it in a popup within H1 (or any other means to show bigger area to make it more selectable)
  4. when user selects the text from this popup, we close the popup and select appropriate text in the original element that user had clicked
Related:  How can I beautify JavaScript code using Command Line?

Here is my attempt: https://jsfiddle.net/vnathalye/rtw5bvLx/6/

$('.expandable').click(function(e){
    var clicked = findClickedWord(e.target.childNodes[0], e.clientX, e.clientY);
  if(clicked){
    var $expanded = $('<span>')
                    .appendTo('body')
                    .addClass('expanded')
                    .css({
                      position: "absolute",
                      left: clicked[3].left,
                      top: clicked[3].top,
                      //width: "100px",
                      //height: "100px"
                    })
                    .append($("<h1>").text(clicked[0]));

        var data = {originalElem: e.target.childNodes[0], index: clicked[1], starts: clicked[2]};

    $expanded.data("parentData", data);
    $expanded.on('mouseup', selectionChanged);
    $expanded.on('touchend touchcancel', selectionChanged);

    //alert(JSON.stringify(clicked));
  }
});

function selectionChanged(e){
try {
    var $expanded = $(e.target);
  var data = $expanded.parents(".expanded").data("parentData");
  var selection = window.getSelection();
  if(selection.rangeCount){
    var range1 = selection.getRangeAt(0);
    //alert(range1.startOffset + ":" + range1.endOffset);

    var range2 = document.createRange();
    var originalOffset = data.index>0? data.starts[data.index-1] : data.starts[0];
    range2.setStart(data.originalElem, originalOffset + range1.startOffset);
    range2.setEnd(data.originalElem, originalOffset + range1.endOffset);
    selection.removeAllRanges();
      selection.addRange(range2);
  }
 } catch(err){
 alert(err);
 }
  $expanded.parents(".expanded").remove();
}

function findClickedWord(parentElt, x, y) {
    if (parentElt.nodeName !== '#text') {
        console.log('didn\'t click on text node');
        return null;
    }
    var range = document.createRange();
    var words = parentElt.textContent.split(' ');
    var start = 0;
    var end = 0;
    var starts=[];
    var ends=[];
    for (var i = 0; i < words.length; i++) {
        var word = words[i];
        end = start+word.length;
        starts.push(start);
        ends.push(end);

        range.setStart(parentElt, start);
        range.setEnd(parentElt, end);
        // not getBoundingClientRect as word could wrap
        var rects = range.getClientRects();
        var clickedRect = isClickInRects(rects);

        if (clickedRect) {
                var str = (i==0)? word : words[i-1] + " " + word;
            if(i!=words.length-1) str += " " + words[i+1];
            return [str, i, starts, clickedRect];
        }
        start = end + 1;
    }

    function isClickInRects(rects) {
        for (var i = 0; i < rects.length; ++i) {
            var r = rects[i]
            if (r.left<x && r.right>x && r.top<y && r.bottom>y)
            {            
                return r;
            }
        }
        return false;
    }
    return null;
}

Note:

  1. Positioning of the popup can be improved to suit your needs. I’ve focused on getting the code for text selection working and have not fine-tuned popup position logic.
  2. With limited time I could test it only in FF on PC and Chrome on Android.
  3. Its the first time I’ve used touch events, so I’m not sure if I’ve used them in best possible way. Any criticism, suggestions are most welcome.

Credits

  1. This code is based on the idea that @mmm started with, to use a dummy element to show the extra area for selection
  2. I’ve used modified version of the code @ https://jsfiddle.net/abrady0/ggr5mu7o/ shared by @TeoDragovic

Let me know your thoughts

References