skip to Main Content

I’m having a challenge getting an accurate starting position in a string with mixed content (HTML and text). The content is entered by users into a contenteditable div. I can’t use textareas because HTML tags can be added to the content and textareas don’t accept anything but text.

In the app, users enter text and then they are given the opportunity to select text for highlighting (the app adds <span class="hilite"></span> tags to the selected text) and "tags" (<span>{{selected text}}<sup>{{tagname}}</sup></span> tags) so that a string can look like this:

a very&nbsp; <span class="hilite">unhappy</span>&nbsp;&nbsp; person <span>made life very difficult<sup>problem</sup></span> for another person 

The &nbsp; items appear to be added by the browser (a unique feature of contenteditable divs?) any time there is more than one space sequentially and this can appear as " &nbsp;" (regular space first) or "&nbsp; " (regular space last) or a series of &nbsp; spaces with a regular space at the beginning or the end.

Because this is user driven input, it’s never known how many tags or &nbsp; spaces will be found in a string. Either way, the goal is to count every character in the literal html string up to the beginning of the selected text.

For example, if the user wants to "tag" the second instance of "person", the user highlights the word, then a right click will pop up a modal with the selected text. At the moment of the right click I need to get an accurate position of the selected text in the context of the whole string as it exists at the time. And, of course, I won’t know how many spans, sups or &nbsp; elements there are or how many instances of "person" there might be.

Here’s my basic event handler for the right click (using jQuery):

$('.editableDiv').on('contextmenu', function(e) {
    e.preventDefault();
    let fullString = $(this).html();
    let selectedText = window.getSelection();
    let textStart = {{what goes here is the problem}}
    $.ajax(
         {{send arguments to server, 
             do stuff, 
             return the amended string 
             replace the contents of the $(this) with the returned string}}
    );
});

The specific problem is how do I set the value of textStart accurately.

JavaScript’s string.indexOf(person) won’t work because it only finds the first instance of person and I’ll never know how many instances there will be since the user is entering the text and selecting the text to manipulate.

I’ve also tried traversing the DOM like this (called with selectedText from above as selection):

function findTheStartPosition(selection) {
    let range = selection.getRangeAt(0);
    let startContainer = range.startContainer;
    let start = 0;
    for (let node of Array.from(startContainer.parentElement.childNodes)) {
        if (node === startContainer) {
            break;
        }
        if (node.nodeType === Node.TEXT_NODE) {
            start += node.textContent.length;
        } else if (node.nodeType === Node.ELEMENT_NODE) {
            let tempDiv = document.createElement('div');
            tempDiv.appendChild(node.cloneNode(true));
            start += tempDiv.innerHTML.length;
        }
    }
    start += range.startOffset;

    return start;
}

And that works well, except when there are &nbsp; elements because range.startOffset counts all &nbsp; characters as single spaces and thus the position is inaccurate.

I’m trying to avoid stripping the main string of all tags and spaces because it will require keeping track of what was stripped and the length of each item stripped (including a count of 6 characters for every &nbsp;) and then having to rebuild the string to preserve the tags (but not the superfluous spaces)….nightmare.

All I need is a simple, reliable way to get the start position of the user selected text in a string.

2

Answers


  1. I suggest either:

    1. Using ProseMirror OR…
    2. Taking inspiration from ProseMirror’s documentation and open source.

    ProseMirror is a (framework for building an) editor that uses contenteditable under the hood.

    You might be able to just modify the ProseMirror basic example to fit your needs. The selection start and end are accessed via let {from, to} = state.selection. (Take a look at the tooltips example.) These examples support inserting HTML objects like images and <HR>‘s.

    I think ProseMirror has several advantages over a plain contenteditable <div>. But they are not without costs like added complexity and a steep learning curve. (For complex, niche use cases you may have to define your own document grammar.)

    So another option is to simply emulate ProseMirror’s method:

    1. ProseMirror keeps track of the document content separately from the contenteditable <div>. This avoids the problem you are facing with &nbsp; being inserted for spaces. (Also I would be surprised if contenteditable behaves exactly the same on all platforms/browsers).
    2. Then ProseMirror renders this document into HTML.

    ProseMirror provides a set of tools and concepts for building rich
    text editors, using a user interface inspired by
    what-you-see-is-what-you-get, but trying to avoid the pitfalls of that
    style of editing.

    The main principle of ProseMirror is that your code gets full control
    over the document and what happens to it. This document isn’t a blob
    of HTML, but a custom data structure that only contains elements that
    you explicitly allow it to contain, in relations that you specified.
    All updates go through a single point, where you can inspect them and
    react to them.

    The core library is not an easy drop-in component—we are prioritizing
    modularity and customizability over simplicity, with the hope that, in
    the future, people will distribute drop-in editors based on
    ProseMirror. As such, this is more of a Lego set than a Matchbox car.

    Login or Signup to reply.
  2. As discussed in the comments, what you really need is replacing the &nbsp;s before selection with regular spaces.

    You didn’t get my suggestion. I didn’t mean re-traversing each time the user enters a new symbol. That would make your website really slow. I meant re-traversing when the user selects something. And startOffset is only used to replace &nbsp;s within the startContainer.

    Anyway, here’s the implementation for you to test.

    function handleNbspsBefore(selection) {
      const range = selection.getRangeAt(0);
      const start = range.startContainer;
      let result = "";
    
      for (let node of Array.from(start.parentElement.childNodes)) 
      {
        if (node === start) break;
    
        if (node.nodeType === Node.TEXT_NODE) {
          result += node.textContent.replace("&nbsp;", " ");
        } else if (node.nodeType === Node.ELEMENT_NODE) {
          result += node.innerHTML.replace("&nbsp;", " ");
        }
      }
    
      result += start.textContent.substring(0, range.startOffset).replace("&nbsp;", " ");
    
      return result;
    }
    
    function getStartOf(selection) {
      return handleNbspsBefore(selection).length;
    }
    

    You no longer need that algorithm from your question, since the traversal is performed in function handleNbspsBefore.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search