MrkrJS

Hero banner image

Description

MrkrJS is a simple JavaScript utility that allows users to highlight arbitrary chunks of text on a page by applying css classes. Check out the demo below!

Demo

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Background

MrkrJS was created because we needed a utility that allowed users to highlight text in multiple choice questions. At the time, I wanted a small, simple package that let you add custom styles to arbitrary text. It was somewhat shocking that I wasn't able to find anything1. I also hadn't published an npm package before and it seemed like a good excuse to do so.

Engineering

MrkrJS is 100% vanilla JavaScript and uses various browser APIs like getRange to apply classes to text nodes.

There are a couple utility classes that can change how you use it, but generally the class requires an HTMLElement and a className.

typescript
// Get HTML element
const element = getElementById('my-text');
const className = 'highlight';

// Instantiate marker will apply `className` to highlighted text
const mrkr = new Mrkr(element, className);

The most challenging part applying classes to arbitrary blocks of text like this is dealing with situations where the user highlights text that spans multiple different HTML elements. For example, take this juicy piece of ent sass:

html
<div>
  <div>
    <h3>Pippin Took</h3>
    <p>
        "And, who's side are you on?"
    </p>
  </div>
  <div>
    <h3>Treebeard</h3>
    <p>
        "Side? I am on <i>nobody's</i> side, because <i>nobody</i> is on my side, little orc."
    </p>
  </div>
</div>

Let's say, for some reason, you wanted to highlight something like this:

We start highlighting in the middle of the first p element and end in the second p element. Unfortunately we can't simply wrap this whole selection in one element because you'd be starting in one element and ending in a sibling element, which is not how the DOM tree works. We have to break down the elements into chunks of text, and wrap each chunk separately. These text chunks are actually text nodes2, which you can think of as raw text elements that have no styling. The span element works well for this because by default it's an inline element. We want to end with something like this:

html
<div>
  <div>
    <h3>Pippin Took</h3>
    <p>
      "And, <span class="highlight">who's side are you   on?"</span>
    </p>
  </div>

  <div>
    <h3><span class="highlight">Treebeard</span></h3>
    <p>
      <span class="highlight">"Side? I am on <i>nobody's</i> side</span>,   because <i>nobody</i> is on my side, little orc."
    </p>
  </div>
</div>

To be able to wrap text nodes like this, we use the window.getSelection() method whenever the user highlights some text, i.e. the pointerup event is triggered, and then we can find the Range of the selection with getRange(). The Range object has some super useful data attached to it, like startContainer, endContainer, startOffset, and endOffset.

Even though we have the start and end data for the selection, we don't really know much about the middle. Furthermore, this highlight spans multiple different element hierarchies. In other words, it starts in a child text node of one element and ands in a child text node of a sibling element. To find the text nodes in between the start and end, we recursively add all the text nodes in the hierarchy into a flattened array of text nodes (in order of appearance on the page), kinda like this:

Pippin Took
"And, who's side are you on?"
"Side? I am on 
nobody's
side, because 
nobody
is on my side, little orc."

Once we have that, we can iterate through the flattened text nodes. If the text node falls between the start and end text nodes, we create a new span node and add a new text node to it, then replace the old text nodes:

typescript
  /**
   * Creates a set of highlighted and non-highlighted nodes to replace the passed text content
   *
   * @private
   * @param {(string | null)} [text='']
   * @param {number} startOffset
   * @param {number} endOffset
   * @returns {ChildNode[]}
   * @memberof Mrkr
   */
  private highlightNode(text: string | null = '', startOffset: number, endOffset: number): ChildNode[] {
    if (!text) return [];

    const highlightedText = text.substring(startOffset, endOffset);

    if (highlightedText.length > 0) {
      const highlightedSpanNode = document.createElement('SPAN');
      highlightedSpanNode.classList.add(this.highlightClass);

      const startTextNode = document.createTextNode(text.substring(0, startOffset));
      const highlightedTextNode = document.createTextNode(highlightedText);
      const endTextNode = document.createTextNode(text.substring(endOffset));

      highlightedSpanNode.appendChild(highlightedTextNode);

      const newNodes = [];
      if (startTextNode.textContent) newNodes.push(startTextNode);
      newNodes.push(highlightedSpanNode);
      if (endTextNode.textContent) newNodes.push(endTextNode);

      return newNodes;
    }

    return [document.createTextNode(text)];
  }

There're a couple other nuances with highlighting HTML in this way, but that's the meat of it! The Mrkr JS also returns a data object that contains various highlighting data, which you can use to determine which text in the page is highlighted or even programmatically highlight.

typescript
{
  startOffset: number;
  endOffset: number;
  text: string;
  node: Text[];
}[]

Beyond the deep dive into text nodes in HTML, this was also an instructive exercise in learning how to configure a package for public consumption on npm for everyone's uninhibited enjoyment.

Stack

MrkrJS itself has zero dependencies, and is built with exclusively with various native JavaScript browser utilities. For the Github website, I used React, specifically create-react-app. For the bundling, I used babel to build the unbundled esm and cjs scripts, and Rollup to handle the bundled scripts.

Made with 🥒 by T. 2024