Get start and end postion of a text selection with sourrounded elements

Viewed 138

I'm trying to make a "annotation" tool - the user can surround some elements in a text (in a div). I need to keep the selection ranges positions of the RAW text.

With this div containing text to annotate:

<div id="annotate">foo bar baz</div>

Using document.getSelection(), and getRangeAt() it possible to use surroundContents() that works. But the problem is that, after having surrounding the first label, I cannot get the "real" text position of the second selection.

After surrounded elements:

<div id="annotate">foo <span class="tag">bar</span> baz</div>

If I select "baz", the anchorStart is 0 instead of 8. That's becauces the "anchorNode" is now a TextNode because there is now a span injected from the surroundContents() method.

I don't find any solution to get the selection position using only the "div" raw content.

NOTE: I tried Rangy library to check if it gives a solution, but no... I also checked how doccano interface does the job, but the code is a bit complicated to decrypt so the method is not easy to understand.

This is an example:

function annotate() {
  let selection = document.getSelection();
  let range = selection.getRangeAt(0);
  
  let pre = document.querySelector("pre");
  pre.innerHTML = `start: ${range.startOffset}`;
  
  let annotation = document.createElement("span");
  annotation.classList.add("box");
  range.surroundContents(annotation);
  
  
  selection.empty();
}
.box {
  background-color: yellow
}
#annotator {
  padding: 1em;
  border: 1px solid black;
}
<p>Try first to select "bar" and annotate. Then select "baz" and annotate. You'll see that the startOffset will be 1.
</p>
<div id="annotator">foo bar baz</div>
<button onclick="annotate()">annotate</button>

<pre>results...</pre>

A special note: I currently make regexp "matchAll" iteration on the parent element "innerHTML" to get index of found tag, remove tag length, and continue. That works but it's not very efficient.

1 Answers

Assuming that the highlighted text does not contain any already-highlighted region (otherwise surroundContents would not work anyway):

  • Use const startContainer = range.startContainer to get the text node that contains the whole range. (this should be the same as range.endContainer)
  • Find the index of it inside the annotator div: const index = [...annotator.childNodes].indexOf(startContainer)
  • The actual start offset is range.startOffset + the sum of the lengths of the previous nodes (annotator.childNodes[0], annotator.childNodes[1], ..., annotator.childNodes[index-1])
  • To compute the text length of a node, use node.nodeValue.length for Text nodes, or node.innerText.length for <span> nodes.

Alternatively:

  • Store the start offset of each node with respect to the original div in an attribute/property of the node.
  • For each added node, compute the new offset for the inside text span(s) and the immediately-after text node.
  • Use that offset to adjust the local offset in each node.

This way you don't have to iterate through all the children to get the offset; however for most practical purposes the number of children is negligible.

Related