Search code examples
javascripthtmlbrowserweb-frontendtextselection

What can I use to reliably get a character offset of a click in Javascript using multiple nodes of text?


I'm using Window.getSelection to get the character offset of where a user clicks, when that user clicks on some text. This works great when there is a single text node, and there are a lot of great answers on SO about how to do so.

However, I'm having a problem when I have two or more text nodes rendered next to each other. Here's a fiddle that replicates the problem, but I'll walk through it here:

Use-case:

I'm building a text editor and controlling the DOM with Javascript that reacts to user keypresses, instead of using a contentEditable container. I would like to track (and show) where in the text a user's "cursor" is (i.e. where the text would be entered if they were to start typing), as well as let them click anywhere in the text to manually set their cursor to where they clicked.

HTML:

<p class="sentence">
  <span class="word" index="0">There </span><span class="word" index="1">is </span><span class="word" index="2">a </span><span class="word" index="3">big, </span><span class="word" index="4">wonderful </span><span class="word" index="5">world.</span>
</p>

Javascript:

$('.word').click(function() {
  let word_index = $(this).attr('index');
  
  let selection  = window.getSelection();
  let char_index = selection.focusOffset;

  console.log('Clicked to set a new cursor @');
  console.log('Word index = ' + word_index.toString() + 
             ' / char index = ' + char_index.toString() );
});

In short, this code prints a word index and a character index for where a cursor would be placed in the text, as if it were an editable input. If you clicked on the left half of the "T" in "There", it'd print

Clicked to set a new cursor @
Word index = 0
/ char index = 0

Clicking on the right side of "T" (such that a cursor would be placed after it but before the "h") would print the same word index, but a char index of 1, since the cursor is now placed one character over, and so on.

This also works great... except for clicking on the left-half of the first character of any word (other than the first). Clicking on the left-half of "i" in "is" (to set a caret at word index 1, char index 0) instead prints word index 1 (correct) and char index 6 (the length of the previous word).

Is Window.getSelection (or more specifically, selection.focusOffset) not the correct way to calculate this kind of character offset when there are multiple text nodes next to each other? Is there another library or method I need to use instead?

One "fix" for the problem is to apply a margin around each word to put a non-clickable gap between them. In this case, clicking the left-half of "i" gives the correct word/char index (1,0 instead of 1,6), but has the side effect of having whitespace where nothing happens if a user happens to accidentally click there (a serious side effect in this application, so it isn't really a viable "fix"). It looks like a margin of at least 3px always returns the correct values, while a margin of 2px or less always returns the incorrect values.

I'm testing in Chrome because I'll eventually be building into an Electron app, so I guess I only really need it to work in Blink, but it'd be wonderful if a solution was browser-agnostic for a web release, too.


Solution update:

I was able to get this working by implementing the following two additional guardrails:

// If we hit an overlap with another bit of selectable
// text, we zero out the cursor offset to avoid using
// that word's offset -- the left edge of a textnode
// is the only place where this happens, so we know
// the correct cursor offset is 0.
if ($(this).text() !== selection.focusNode.wholeText)   {
  char_index = 0;
}

// There's also a small clickable area on the right
// side of the final character in a word that would
// result in a cursor at the end of that word (often
// where the delimiting space is). We simply shift
// the cursor's reference from end-of-that-word to
// start-of-the-next word, which are the same to the
// user, but makes more sense for keeping proper
// word individuation.
if (char_index === $(this).text().length) {
  word_index += 1;
  char_index  = 0;
}

There's more work to make it cross-browser, but here is an updated fiddle that uses these adjustments to get the desired values in Chrome.


Solution

  • The default behavior will be browser dependent so you will need to do some checking in order give consistent results.

    Here is a simple example which compares the event.target element with the parentElement of the text node returned by selection.focusNode, if there is a mismatch it checks if the focus node's sibling matches and adjusts the offset accordingly.

    You will need to make this more robust. You will also need to handle directionality (selecting left to right yields reversed focus and anchor nodes to selecting right to left). You might also look at Selection.containsNode()

    function handleSelection(e) {
      const selection = window.getSelection();
      
      const fNode = selection.focusNode;
      
      const fElement = fNode.parentElement;
      const tElement = e.target;
      
      let word_index = tElement.dataset.index;
      let char_index = selection.focusOffset;
      
      if (!fElement.isEqualNode(tElement) 
        && fElement.nextElementSibling.isEqualNode(tElement)) {
        char_index=0;
      }
      
      console.log(
        'fNode: ', fNode.parentElement.innerText,
        ' tNode: ', tElement.innerText,
        ' char: ', char_index,
        ' word: ', word_index
      );
    
    }
    
    const words = document.querySelectorAll('.word');
    words.forEach((w, i) => (
      w.dataset.index = i+1, 
      w.addEventListener('click', handleSelection)));
    .word {
      border: 1px solid gray;
      font-size: 2rem;
    }
    <p class="sentence">
      <span class="word">Hello </span><span class="word">wonderful</span><span class="word"> world.</span>
    </p>