0

For a text annotation app I would like to programmatically color spans of text within a given div using previous user annotations, which are saved in the database as character offsets, e.g. - color character 0 to 6, color from 9 to 12, etc.

My approach has been to get the first #text node within the div (using an answer from 1), create a range there, and then insert the span there. But after the first span is inserted the first there is no longer a text element that contains all of the text in the div, and this fails:

function color_x_y(elementname, x, y){ // color from coordinate x to y, within div elementname

    elem = get_text_node(elementname);

    range = document.createRange();
    range.setStart(elem, x);
    range.setEnd(elem, y);

    span = document.createElement("span");
    span.style.border = "5px solid red";

    range.expand();
    span.appendChild(range.extractContents()); 
    range.insertNode(span);
}

function get_text_node(node){
    var oDiv = document.getElementById(node);
    var firstText = "";
    for (var i = 0; i < oDiv.childNodes.length; i++) {
        var curNode = oDiv.childNodes[i];
        if (curNode.nodeName === "#text") {
            firstText = curNode;
            break;
        }
    }
    return firstText;
}

I am new to javascript and would appreciate any ideas on how to accomplish this.

Thanks!

EDIT

I should note that highlights may overlap with one another.

stanga
  • 37
  • 1
  • 1
  • 6
  • Do you want to do the coloring all at once - in which case just keep a copy of the original text and gradually copy across with span elements where required to a new string - or do you want to be able to add a highlighted bit to an existing text which already has some hightlights in it? I infer this latter is the case because of the way you have introduced a function with parameters element and x and y. In that case a bit more code is required. – A Haworth Mar 03 '21 at 11:41
  • This function is meant to recreate highlights created elsewhere, so the coloring can be done at once - I have a separate function that allows the user to add highlights, based on selection range, and it works well with existing annotations. I do need to allow for possible overlaps between highlights, though. – stanga Mar 03 '21 at 16:25

2 Answers2

1

I set the annotation span class name annotate so the script can get rid of inline style.

I revised the getTextNode() looping order. Because for 2+ annotations, the first text node will be your first annotation. Therefore, I loop the childNode starting from the end.

Finally, the more annotation inserted, the more childNodes would be, and more content will be extract from it. Therefore, your “target” text node will become shorter (e.g. to color 4 to 7, 7 will be your new 0). To compensate the loss of content, the coordinate need an offset.

p.s. name your function camelCase

function colorRange(elementname, x, y) { // color from coordinate x to y, within div elementname

  elem = getTextNode(elementname);

  range = document.createRange();


  // compensate the shorter text length
  var offset = document.getElementById(elementname).innerText.length - elem.length;
  range.setStart(elem, x - offset);
  range.setEnd(elem, y - offset);

  span = document.createElement("span");
  span.className = "annotate";

  range.expand();
  span.appendChild(range.extractContents());
  range.insertNode(span);
}

function getTextNode(node) {
  var oDiv = document.getElementById(node);
  var lastText = "";
  // loop from the end
  for (var i = oDiv.childNodes.length - 1; i >= 0; i--) {
    var curNode = oDiv.childNodes[i];
    if (curNode.nodeName === "#text") {
      lastText = curNode;
      break;
    }
  }
  return lastText;
}

colorRange('lorem', 0, 5); // annotate "Loerm"
colorRange('lorem', 12, 17); // annotate "dolor"
colorRange('lorem', 116, 124); // annotate "pulvinar"
.annotate {
  border: 5px solid red;
}
<div id="lorem">Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, sit amet commodo magna eros quis urna.</div>
Mr. Brickowski
  • 1,134
  • 1
  • 8
  • 20
  • Thank you, this is very helpful! But if I am not wrong this solution depends on processing the tags by order, and in my application it is possible for example that the tag (5, 10) is received before (1, 6). Moreover, in my case tags may overlap. I have had no problems generating these overlapping and out-of-order spans from user selection ranges, but I am unsure how to create them when the data source is external. – stanga Mar 03 '21 at 16:21
  • Due to the fact that every time `colorRange()` is involved, the target element and its child will change. This will affect how the function is called. We need to know how the user trigger this coloring feature? – Mr. Brickowski Mar 04 '21 at 13:02
1

In allow for overlaps in highlighting and for ranges of highlighting not coming in numerical order, we'll need to build up the element innerHTML bit by bit on each call of the colorXY function, taking into consideration any existing highlighting.

We can do this most easily by putting both highlighted and unhighlighted segments into span elements so JavaScript will do the parsing of the HTML associated with the elements for us.

Here's a snippet which does this on a 'vanilla' text - that is, text that does not contain other HTML elements. I guess in the real case you will want to count character positions ignoring any intervening HTML, but keep the HTML in the final result. This needs a slight tweak to the below, but I hope this is enough to get things started.

Also note that span presents a problem in that not all HTML can be 'legally' embedded within a span element. The question mentioned span so it is used here, but it may be necessary to either parse any embedded HTML further, to ensure span elements do not straddle them, or possibly to use divs - but even so nesting of HTML elements will have to be preserved. This will require e.g. sensing when you are within say a p element.

let inputX = document.getElementById('x');
let inputY = document.getElementById('y');

// NOTE: counting starts at 0 - so for the first character in the string x=0

function colorXY(element, x, y) {
  x = Number(x);
  y = Number(y);
  if (y<x) { alert('Error: y<x'); return; }

  let els = element.querySelectorAll('.highlighted, .unhighlighted');
  let ranges = []; //will hold start and end character position of string within each special span

  let i; //working index
  
  if (els.length == 0) { //first time we've handled this element so make it all an unhighlighted span
    element.innerHTML = '<span class="unhighlighted">' + element.innerHTML + '<\/span>';
    els = element.querySelectorAll('.unhighlighted');
  }
  
  //extract all the text to get the original string and the range of each special span
  let originalStr = '';
  let chNo = 0;
  for (i = 0; i < els.length; i++) {
    originalStr += els[i].innerHTML;
    ranges[i] = [chNo, chNo + els[i].innerHTML.length - 1];
    chNo = ranges[i][1] + 1;
  }

  if (x > (originalStr.length - 1) || y > (originalStr.length - 1) ) {alert('x and/or y is larger then the length of the text'); return;}

  element.innerHTML = '';
  
  function wrap(highlight, first, last) { // wrap the characters from first to last in a span element and add it to the element
    if ((first >= 0) && (last >= first)) {
      wrapStr(highlight, originalStr.slice(first, last+1));
    }
  }

  function wrapStr(highlight, str) {
      element.innerHTML = element.innerHTML + '<span class="' + (highlight ? 'highlighted' : 'unhighlighted') + '">' + str + '<\/span>';
 }

  function elHighlighted(el) { // true if element has a class highlighted, false otherwise
    return !el.classList.contains('unhighlighted')
  }
  
//now go through the special elements we already have to see where our new range fits in - there may be overlaps

  let startEl = false; //will be set to the element number that contains the character at x
  let startX = 0; //will be set to the number of the first character in this element
  let startI = 0; //will be set to its index
  let endEl = false; //will be set to the element number that contains the character at y
  let EndY = 0; //will be set to its index

  for (i = 0; i < els.length; i++) {
    if (x >= ranges[i][0] && x <= ranges[i][1]) { // it starts in this element
      startEl = els[i];
      startX = ranges[i][0];
      startI = i;
    }
    if (y >= ranges[i][0] && y <= ranges[i][1]) { // it ends in this element
      endEl = els[i];
      endY = ranges[i][1];
      endI = i;
    }
  }

// now we know where the x,y range lies we can copy across non-involved elements
// we only have to do special stuff when we see the startEl
  for (i = 0; i < startI; i++) {
     wrapStr(elHighlighted(els[i]), els[i].innerHTML);
}

  if (elHighlighted(startEl) && elHighlighted(endEl)) { // both elements already highlighted
    wrap(true, startX, endY);
  }
  else if (elHighlighted(startEl)) { //first one already highlighted, second one not
    wrap(true, startX, y);
    wrap(false, y+1, endY);
  }
  else if (elHighlighted(endEl)) { // first one not highlighted, second one is
    wrap(false, startX, x-1);
    wrap(true, x, endY);
  }
  else { //neither of them highlighted   
    wrap(false, startX, x-1);
    wrap(true, x, y);
    wrap(false, y+1, endY);
  }

//now copy across the rest
  for (i = (endI + 1); i < els.length; i++) {   
     wrapStr(elHighlighted(els[i]), els[i].innerHTML);
  }
} 
.highlighted {
  background-color: yellow;
}
.unhighlighted {
  background-color: transparent;
}
<label for="x">x: <input id="x" type="number" /></label>
<label for="y">y: <input id="y" type="number" /></label>
<button onclick="colorXY(document.querySelector('.thedata'), inputX.value, inputY.value);">Click to highlight</button>
<p>(Note: counting starts at 0)</p>

<div class="thedata">Lorem ipsum dolor sit amet. Ea pariatur veritatis et commodi voluptas quo quaerat voluptates qui natus dolor et quia Quis! Et quia labore non minus mollitia ut magnam dicta ea impedit facilis ut animi perspiciatis eos amet totam.
Non earum doloremque ea nobis impedit sed totam consequuntur et omnis totam et modi sunt et perferendis tempore. At eaque totam aut amet omnis et magni laboriosam et enim amet. Sit aperiam quos quo deserunt harum eum laboriosam explicabo.Ut exercitationem quidem et voluptatum harum et repellat fugiat vel consequatur exercitationem ut sunt molestias qui galisum laudantium et esse quam. Quo veniam nihil ex maxime facilis sed natus nihil. Et aliquam tenetur qui fugiat placeat et quae repudiandae aut pariatur adipisci ut Quis optio. Id culpa labore aut labore nisi cum voluptatem molestiae ab accusamus voluptas sed fugit dolores ut doloremque cumque.
</div>
A Haworth
  • 30,908
  • 4
  • 11
  • 14