1

In JavaScript range selection is it possible to prevent selection of a partial node?

For example:

"The enormous cat sat on the very small rug."

A user might select "cat" and more often than not, their mouse selection is not that precise and includes the visible space either side as well, and thus the selection range nearly always includes "enormous" and "sat" which we do not want.

Each span contains a single word. The visible space in between words could be true whitespace inside a span tag, spans stacked with line-breaks, it could be padding, it also could be css word-space, or even a non-breaking space. Whichever way if the user's selection strays into another node unintentionally, the default is of course is to return the node as part of the selection.

How can this be avoided?

Any pointers gladly accepted.

Thank you kindly.

Example code:

<span id="a1">The </span>
<span id="a2">enormous</span>
<span id="a3"> cat </span><span id="a4">sat</span>
<span id="a5"> on </span><span id="a6" style="padding-right: 2px;">the</span>
<span id="a7">very </span><span id="a8">small </span><span id="a9">rug</span><span id="a10">. </span>
mplungjan
  • 169,008
  • 28
  • 173
  • 236
user3012857
  • 165
  • 1
  • 10
  • 1
    Do you want to limit selection to ONE word only, or am I missing something? – Jan Pfeifer Jan 18 '23 at 11:28
  • Hi thanks for responding. The user could wish to select multiple words and maybe across line breaks. Sorry if not clear in the question. What we want is to keep all words that have been fully selected and discard those that have been included because the user's selection has crossed the boundary of a neighbouring word/node, albeit not visible to them, they just assume it is whitespace but in fact it has included that neighbouring span. It is that we wish to avoid. – user3012857 Jan 18 '23 at 11:51
  • Ok, are the span nodes necessary? Can it be a plain text? – Jan Pfeifer Jan 18 '23 at 12:10
  • The spans or other html tag are necessary as we then grab the id – user3012857 Jan 18 '23 at 12:21

2 Answers2

1

Here is a script you can build on using Selection API

const container = document.getElementById("container");
const spans = container.querySelectorAll('span');
document.addEventListener('selectionchange', (e) => {
  const sel = window.getSelection();
  const start = sel.anchorNode.parentNode;
  const end = sel.focusNode.parentNode;
  const partialContainment = false;
  if (start != end) {
    console.log("Words: Start", start.textContent, "End:", end.textContent);
    let started = false;
    spans.forEach(span => {
      span.classList = "";

      console.log("containsNode partial", span.id, span.textContent, ':', sel.containsNode(span, partialContainment));
      if (span === start) {
        span.classList.add('start');
        started = true;
      } else if (span === end) {
        span.classList.add('end');
        started = false;
      } else if (started) {
        span.classList.add('middle');
      }
    })
    const middleSpans = [...document.querySelectorAll('span.middle')].map(span => ({
      [span.id]: span.textContent.trim()
    }));
    console.log(JSON.stringify(middleSpans))
  }
})
.start {
  color: green
}

.middle {
  color: orange
}

.end {
  color: red
}
<div id="container">
  <span id="a1">The </span>
  <span id="a2">enormous</span>
  <span id="a3"> cat </span><span id="a4">sat</span>
  <span id="a5"> on </span><span id="a6" style="padding-right: 2px;">the</span>
  <span id="a7">very </span><span id="a8">small </span><span id="a9">rug</span><span id="a10">. </span>
</div>
mplungjan
  • 169,008
  • 28
  • 173
  • 236
  • This is interesting but exhibits the exact problem. If I select "cat" and the whitespace either side your script picks up the nodes "enormous" and "sat" as well? – user3012857 Jan 18 '23 at 11:54
  • @user3012857 You talk about selection, but how do you make this selection? Just with a click on the word...? By double clicking on the word...? By selecting the text while holding down the mouse button...? I admit I don't quite understand... – Juan Jan 18 '23 at 11:58
  • 1
    Yes, so you have the document, you have the selection, you have the spans inside the selection. So if I understand you correct, you want to drop the spans on either side of a selected span what if there are 4 or 5 spans?) so compare the start and end with each selected span and see which to drop. I just gave you the tools, but your usecase was too unclear for me to give you the whole script – mplungjan Jan 18 '23 at 11:59
  • Hi both of you, I'll try to explain more clearly. The selection could be made by clicking on a word and holding the mouse down to select multiple words. It could start from one word and select any number of words to the left, or it could start from one word and select any number of words to the right. It's a user selecting text from a transcript in the normal way that's all but the problem arises when they don't quite select the start or end of their selection precisely. – user3012857 Jan 18 '23 at 12:18
  • Their selection often includes white space to the left of the start of the selection or to the right of the end. It is there that the problem starts because that whitespace can be part of another node/span but they cannot see that themselves it is just white space, so as in the title of the question, how can we avoid including in the returned selection those nodes whose words are only partially selected. I hope that's now clearer. I hope so because the usage case requires accuracy and its driving me mad. – user3012857 Jan 18 '23 at 12:19
  • See update. We are a lot closer I think – mplungjan Jan 18 '23 at 12:19
  • 1
    Yes I think so. Bear with me until I can fully test in about 30 mins – user3012857 Jan 18 '23 at 12:28
  • I'm very appreciative of your time. Definitely closer. Works 7 out of 10 times it seems. The one that still escapes is when a selection starts in the white space to the left or right of a word. For example I started the selection in the space between "The" and "enormous" and selected just the word enormous. The script returns "The" as the word selected and not "enormous". – user3012857 Jan 18 '23 at 13:17
  • 1
    I just found this article about partialContainment "When true, containsNode() returns true when a part of the node is part of the selection. When false, containsNode() only returns true when the entire node is part of the selection. If not specified, the default value false is used" It seems in the area that I'm looking at but I have no idea how to work that in or whether it won't apply in this case – user3012857 Jan 18 '23 at 13:17
  • Ok, we are even closer. Please play with `const partialContainment = false;` by setting it to true. If set to true, you can compare selection with whitespace in the spans – mplungjan Jan 18 '23 at 14:06
  • I'm extremely grateful for your efforts and in the absence of any other solutions I would have picked yours but in this case Jan's below is closer. – user3012857 Jan 18 '23 at 15:00
  • No problem -- it was a better solution – mplungjan Jan 18 '23 at 15:14
  • ...and thanks for the extra syntax ;-) – user3012857 Jan 18 '23 at 15:29
1

I think i have finally nailed it. Lets take start and end span from selection range and check if trimmed span content equals to trimmed span selection (that part that is actually selected in span). If there is only white space selected or partially selected text in span exclude it from selection. Check for parentNode !== SPAN is for case when only white space is selected.

$(function() {
  $(document).on("mouseup", function(e) {
    const range = document.getSelection().getRangeAt(0);
    let start = range.startContainer.parentNode;
    let end = range.endContainer.parentNode;

    if (start === end) {
      if (start.nodeName !== "SPAN") {
        start = null;
        end = null;
      } else {
        const spanContent = start.textContent.slice().trim();
        const spanSelection = range.endContainer.textContent.slice(0, range.endOffset).trim()
        
        if (spanContent !== spanSelection) {
          start = null;
          end = null;
        }
      }
    } else {
      if (end.nodeName !== "SPAN") {
        end = range.endContainer.previousElementSibling;
      } else if (range.endOffset > 0) {
        const spanContent = range.endContainer.textContent.trim();
        const spanSelection = range.endContainer.textContent.slice(0, range.endOffset).trim()
        
        if (!spanSelection || spanContent !== spanSelection) {
          end = end.previousElementSibling;
        }
      }
      if (start.nodeName !== "SPAN") {
        start = range.startContainer.nextElementSibling;
      } else if (range.startOffset > 0) {
        const spanContent = range.startContainer.textContent.trim();
        const spanSelection = range.startContainer.textContent.slice(range.startOffset).trim()

        if (!spanSelection || spanContent !== spanSelection) {
          start = start.nextElementSibling;
        }
      }
    }
    if (start) {
      const x = $(start).index();
      const y = $(end).index();
      const childs = $(start.parentNode).children();
      const result = [];
      
      for (let i = x; i <= y; i++) {
        result.push(childs.eq(i).text().trim());
      }
      console.log(result);
    }
  });
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

<div id="test">
  <span id="a1">The </span>
  <span id="a2">enormous</span>
  <span id="a3"> cat </span><span id="a4">sat</span>
  <span id="a5"> on </span><span id="a6" style="padding-right: 2px;">the</span>
  <span id="a7">very </span><span id="a8">small </span><span id="a9">rug</span><span id="a10">. </span>
</div>

UPDATE 19.1.2023: Another solution can be using selection manipulation as we can expand/shrink selection to the word boundary. See How do I extend selection to word boundary using JavaScript, once only? The idea is to shrink selection to the word boundary, get selected text, trim and split by regex (one or more whitespace characters):

$(function() {
    $(document).on("mouseup", function(e) {
        const sel = window.getSelection();
        if (!sel.isCollapsed) {
            // Detect if selection is backwards
            const range = document.createRange();
            range.setStart(sel.anchorNode, sel.anchorOffset);
            range.setEnd(sel.focusNode, sel.focusOffset);
            const backwards = range.collapsed;
            range.detach();

            // modify() works on the focus of the selection
            const endNode = sel.focusNode, endOffset = sel.focusOffset;
            sel.collapse(sel.anchorNode, sel.anchorOffset);

            const direction = !backwards ? ['backward', 'forward']: ['forward', 'backward'];

            sel.modify("move", direction[0], "character");
            sel.modify("move", direction[1], "word");
            sel.extend(endNode, endOffset);
            sel.modify("extend", direction[1], "character");
            sel.modify("extend", direction[0], "word");

            console.log( sel.toString().trim().split(/\s+/) );
        }
    });
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div id="test" style="margin: 20px;">
  <span id="a1">The </span>
  <span id="a2">enormous</span>
  <span id="a3"> cat </span><span id="a4">sat</span>
  <span id="a5"> on </span><span id="a6" style="padding-right: 2px;">the</span>
  <span id="a7">very </span><span id="a8">small </span><span id="a9">rug</span><span id="a10">. </span>
</div>

Note: strange thing is that selection manipulation works fine on my test page, but not in SO code snippet.

Jan Pfeifer
  • 2,854
  • 25
  • 35
  • Excellent. It's so close. The only one that still is an issue is if you start the selection with a "The" and move into the white space to the right. It still returns "enormous" but to be honest you're so close this is good enough for my purposes and am happy to select your answer, but feel free to work on it more haha. Wish I could award a bounty for this and do you know in my opinion this should actually be the default behaviour when selecting text inside tags. Well done. – user3012857 Jan 18 '23 at 15:01
  • 2
    Alternative syntaxes jQuery: `$(start.parentNode).children().slice(x,y+1).map((i,ele) => ele.textContent.trim()).get();` or plain: `[...start.parentNode.children].slice(x,y+1).map(span => span.textContent.trim());` – mplungjan Jan 18 '23 at 15:24
  • @user3012857 Strange, when I try to replicate described issue I always get nothing instead of 'enormous'. I have fixed code to return 'the'. Removed `parentNode` from first `if (start.parentNode.nodeName !== "SPAN")` check. – Jan Pfeifer Jan 19 '23 at 07:57
  • Good morning, I'm getting the same thing, returning nothing in places. It's a good sound idea and regex solutions are great but I think the first version is actually better? – user3012857 Jan 19 '23 at 09:16
  • @user3012857 It depends what suits you better. What proves to be more reliable. When and how you want to capture the text actually. For example if I would to get the text in button click handler I would stick with latter option. Just to avoid messing with nodes. – Jan Pfeifer Jan 19 '23 at 09:29
  • I'll plug both into the little app I'm working on and see which tests better in the long term. With many thanks and that's probably enough of enormous cats, we'll have nightmares. ;-) – user3012857 Jan 19 '23 at 09:40