text-highlighter.js

import dom from "./utils/dom";
import { bindEvents, unbindEvents } from "./utils/events";
import Primitivo from "./highlighters/primitivo";
import Independencia from "./highlighters/independencia";
import { DATA_ATTR, IGNORE_TAGS } from "./config";
import { createWrapper } from "./utils/highlights";

const highlighters = {
  primitivo: Primitivo,
  "v1-2014": Primitivo,
  independencia: Independencia,
  "v2-2019": Independencia,
};

const versionNames = {
  "v1-2014": "Primitivo (v1-2014)",
  primitivo: "Primitivo (v1-2014)",
  "v2-2019": "Independencia (v2-2019)",
  independencia: "Independencia (v2-2019)",
};

/**
 * TextHighlighter that provides text highlighting functionality to dom elements.
 */
class TextHighlighter {
  /**
   * Creates wrapper for highlights.
   * TextHighlighter instance calls this method each time it needs to create highlights and pass options retrieved
   * in constructor.
   *
   * @param {object} options - the same object as in TextHighlighter constructor.
   * @param {Document} doc - the document to create the wrapper with.
   * @returns {HTMLElement}
   */
  static createWrapper(options, doc = document) {
    return createWrapper(options, doc);
  }

  /**
   * Creates TextHighlighter instance and binds to given DOM elements.
   *
   * @param {HTMLElement} element - DOM element to which highlighted will be applied.
   * @param {object} [options] - additional options.
   * @param {string} options.version - The version of the text highlighting functionality to use.
   * There are three options:
   *   primitivo (v1-2014) is for the initial implementation using interdependent highlight locators.
   *   (Lots of issues for requirements beyond simple all or nothing highlights)
   *
   *   independencia (v2-2019) is for an improved implementation focusing on making highlights independent
   *   from eachother and other element nodes within the context DOM object. v2 uses data attributes
   *   as the source of truth about the text range selected to create the original highlight.
   *   This allows us freedom to manipulate the DOM at will and handle overlapping highlights a lot better.
   *
   * @param {string} [options.color=#ffff7b] - highlight color.
   * @param {string[]} [options.excludeNodes=["SCRIPT", "STYLE", "SELECT", "OPTION", "BUTTON", "OBJECT", "APPLET", "VIDEO", "AUDIO", "CANVAS", "EMBED", "PARAM", "METER", "PROGRESS"]] - Node types to exclude when calculating offsets and determining where to inject highlights.
   * @param {string} [options.highlightedClass=highlighted] - class added to highlight, 'highlighted' by default.
   * @param {string} [options.namespaceDataAttribute=data-highlighted] - Namespace data attribute to identify highlights for a particular highlighter instance.
   * @param {boolean} options.excludeWhiteSpaceAndReturns - Whether or not to exclude white space and carriage returns while calculating text content
   *                                                        offsets. The white space that is excluded is only the white space that comes directly
   *                                                        after carriage returns.
   * @param {Record<string, number>} options.priorities - Defines priorities for multiple highlighters, the keys
   *                                                      are the namespaces for highlighters and the values are the priorities
   *                                                      where the higher number has the higher priority.
   *                                                      For example { userHighlights: 1, staticHighlights: 2 } would mean
   *                                                      that highlights from the "static" highlighter will always appear above highlights
   *                                                      from the "user" highlighter.
   * @param {string} [options.contextClass=highlighter-context] - class added to element to which highlighter is applied,
   *  'highlighter-context' by default.
   * @param {boolean} [options.useDefaultEvents=true] - Whether or not to use the default events to listen for text selections.
   *  The default events are "mouseup" and "touchend". Set this to false and register TextHiglighter.highlightHandler with your own events.
   *  It is down to you to remove the listener from your custom events when destroying instances of the text highlighter.
   * @param {boolean} [options.normalizeElements=false] - Whether or not to normalise elements on the DOM when highlights are created, deserialised
   *  into the DOM, focused and deselected. Normalising events has a huge performance implication when enabling highlighting for a root element
   *  that contains thousands of nodes. This only applies for the independencia v2-2019 version.
   * @param {boolean} [options.keepRange=false] - Whether or not to keep the highlight selection after highlights are created. If set to true then the selection will stay active.
   * @param {boolean} [options.highlightWhiteSpaceChars=false] - Whether or not to deserialise highlights into the DOM when they only contain white space characters.
   * @param {function} options.onRemoveHighlight - function called before highlight is removed. Highlight is
   *  passed as param. Function should return true if highlight should be removed, or false - to prevent removal.
   * @param {function} options.onBeforeHighlight - function called before highlight is created. Range object is
   *  passed as param. Function should return true to continue processing, or false - to prevent highlighting.
   * @param {function} options.preprocessDescriptors - function called when a user has made a selection to create a highlight,
   *   this is called before the highlight are loaded into the DOM. This should be used to carry out tasks like customising the span wrapper
   *   used to inject highlights with data attributes specific to your application. (This is only utilised by v2-2019 onwards)
   * @param {function} options.onAfterHighlight - function called after highlight is created. Array of created
   *   wrappers is passed as param. This is called once the highlights have been loaded into the DOM.
   *   (The callback interface differs between versions, see specific highlighter classes for more info)
   *
   * @param {boolean} registerEventsOnConstruction - Whether or not to attempt to register events when the text highlighter is first instantiated.
   *   In the case options.useDefaultEvents is false, even with this enabled the events won't be registered, this is only relevant if you want more
   *   control and register events at a later point.
   */
  constructor(element, options = {}, registerEventsOnConstruction = true) {
    if (!element) {
      throw new Error("Missing anchor element");
    }

    this.el = element;
    this.options = {
      color: "#ffff7b",
      highlightedClass: "highlighted",
      contextClass: "highlighter-context",
      version: "independencia",
      useDefaultEvents: true,
      excludeNodes: IGNORE_TAGS,
      excludeWhiteSpaceAndReturns: false,
      namespaceDataAttribute: DATA_ATTR,
      priorities: {},
      normalizeElements: false,
      keepRange: false,
      highlightWhiteSpaceChars: false,
      cancelProperty: "cancel",
      onRemoveHighlight: function() {
        return true;
      },
      onBeforeHighlight: function() {
        return true;
      },
      preprocessDescriptors: function(_, hlts) {
        // We need to return the highlight descriptors parameter by
        // default in order to create highlights in the DOM.
        // Also an empty meta object is needed given that it is expected in the interface.
        return { descriptors: hlts, meta: {} };
      },
      onAfterHighlight: function() {},
      ...options,
    };

    this.highlightHandler = this.highlightHandler.bind(this);

    if (!highlighters[this.options.version]) {
      throw new Error("Please provide a valid version of the text highlighting functionality");
    }

    this.highlighter = new highlighters[this.options.version](this.el, this.options);

    dom(this.el).addClass(this.options.contextClass);

    if (registerEventsOnConstruction) {
      this.registerDefaultEvents();
    }
  }

  /**
   * Permanently disables highlighting.
   * Unbinds events and remove context element class.
   * @memberof TextHighlighter
   */
  destroy() {
    if (this.options.useDefaultEvents) {
      unbindEvents(this.el, this);
    }
    dom(this.el).removeClass(this.options.contextClass);
  }

  /**
   * Registers the default event listeners that trigger the proecss
   * of creating a highlight.
   *
   * @memberof TextHighlighter
   */
  registerDefaultEvents() {
    if (this.options.useDefaultEvents) {
      bindEvents(this.el, this);
    }
  }

  /**
   * Listener to events that can trigger the creation of a highlight.
   * By default this is triggered  on "mouseup" and "touchend" events.
   * If you disable the default events by setting options.useDefaultEvents
   * you will need to register this handler with your own events and make sure you
   * remove the listener when you destroy the instance of the TextHighlighter as well.
   */
  highlightHandler() {
    this.doHighlight();
  }

  doHighlight() {
    this.highlighter.doHighlight(this.options.keepRange);
  }

  /**
   * Highlights range.
   * Wraps text of given range object in wrapper element.
   * @param {Range} range
   * @param {HTMLElement} wrapper
   * @returns {Array} - array of created highlights.
   * @memberof TextHighlighter
   */
  highlightRange(range, wrapper) {
    return this.highlighter.highlightRange(range, wrapper);
  }

  /**
   * Normalizes highlights. Ensure at least text nodes are normalized, carries out some flattening and nesting
   * where necessary.
   *
   * @param {Array} highlights - highlights to normalize.
   * @returns {Array} - array of normalized highlights. Order and number of returned highlights may be different than
   * input highlights.
   * @memberof TextHighlighter
   */
  normalizeHighlights(highlights) {
    return this.highlighter.normalizeHighlights(highlights);
  }

  /**
   * Sets highlighting color.
   * @param {string} color - valid CSS color.
   * @memberof TextHighlighter
   */
  setColor(color) {
    this.options.color = color;
  }

  /**
   * Returns highlighting color.
   * @returns {string}
   * @memberof TextHighlighter
   */
  getColor() {
    return this.options.color;
  }

  /**
   * Removes highlights from element. If element is a highlight itself, it is removed as well.
   * If no element is given, all highlights all removed.
   * @param {HTMLElement} element - element to remove highlights from.
   *                                 if empty, the root element of the highlighter will  be used.
   * @param {string} id - The unique id of a highlight represented by a collection of elements.
   * @memberof TextHighlighter
   */
  removeHighlights(element, id) {
    this.highlighter.removeHighlights(element, id);
  }

  /**
   * Returns highlights from given container.
   * @param params
   * @param {HTMLElement} [params.container] - return highlights from this element. Default: the element the
   * highlighter is applied to.
   * @param {boolean} [params.andSelf] - if set to true and container is a highlight itself, add container to
   * returned results. Default: true.
   * @param {string} [params.dataAttr] - Namespaced used to identify highlights for a specific highlighter instance.
   * @param {boolean} [params.grouped] - if set to true, highlights are grouped in logical groups of highlights added
   * in the same moment. Each group is an object which has got array of highlights, 'toString' method and 'timestamp'
   * property. Default: false.
   * @returns {Array} - array of highlights.
   * @memberof TextHighlighter
   */
  getHighlights(params) {
    return this.highlighter.getHighlights(params);
  }

  /**
   * Returns true if element is a highlight.
   * All highlights have 'data-highlighted' attribute.
   * @param el - element to check.
   * @param dataAttr - namespace used to identify highlights for a specific highlighter instance.
   * @returns {boolean}
   * @memberof TextHighlighter
   */
  isHighlight(el, dataAttr = DATA_ATTR) {
    return this.highlighter.isHighlight(el, dataAttr);
  }

  /**
   * Serializes all highlights in the element the highlighter is applied to.
   * the id is not used in the initial version of the highlighter.
   *
   * @param {string} id - The unique identifier grouping a set of highlight elements together.
   * @returns {string} - stringified JSON with highlights definition
   * @memberof TextHighlighter
   */
  serializeHighlights(id) {
    return this.highlighter.serializeHighlights(id);
  }

  /**
   * Deserializes highlights.
   * @throws exception when can't parse JSON or JSON has invalid structure.
   * @param {object} json - JSON object with highlights definition.
   * @returns {Array} - array of deserialized highlights.
   * @memberof TextHighlighter
   */
  deserializeHighlights(json) {
    return this.highlighter.deserializeHighlights(json);
  }

  /**
   * Finds and highlights given text.
   * @param {string} text - text to search for
   * @param {boolean} [caseSensitive] - if set to true, performs case sensitive search (default: true)
   * @memberof TextHighlighter
   */
  find(text, caseSensitive) {
    let wnd = dom(this.el).getWindow(),
      scrollX = wnd.scrollX,
      scrollY = wnd.scrollY,
      caseSens = typeof caseSensitive === "undefined" ? true : caseSensitive;

    dom(this.el).removeAllRanges();

    if (wnd.find) {
      while (wnd.find(text, caseSens)) {
        this.doHighlight(true);
      }
    } else if (wnd.document.body.createTextRange) {
      let textRange = wnd.document.body.createTextRange();
      textRange.moveToElementText(this.el);
      while (textRange.findText(text, 1, caseSens ? 4 : 0)) {
        if (
          !dom(this.el).contains(textRange.parentElement()) &&
          textRange.parentElement() !== this.el
        ) {
          break;
        }

        textRange.select();
        this.doHighlight(true);
        textRange.collapse(false);
      }
    }

    dom(this.el).removeAllRanges();
    wnd.scrollTo(scrollX, scrollY);
  }

  /**
   * Focuses a highlight, bringing it forward in the case it is sitting behind another
   * overlapping highlight, or a highlight it is nested inside.
   *
   * This is only supported by independencia (v2-2019) and onwards.
   * For older versions, this will simply do nothing.
   *
   * @param {string} id - The id of the highlight present in the class names of all elements
   *                      in the DOM that represent the highlight.
   *
   * @param {string} descriptors - Optional serialised descriptors, useful in the case a highlight has no representation in the DOM
   *                        where empty highlight wrapper nodes are removed to use less dom elements.
   *
   * @memberof TextHighlighter
   */
  focusUsingId(id, descriptors) {
    if (this.highlighter.focusUsingId) {
      this.highlighter.focusUsingId(id, descriptors);
    } else {
      console.warn(
        `The ${
          versionNames[this.options.version]
        } version of the text highlighter does not support focusing highlights.`,
      );
    }
  }

  /**
   * Deselects a highlight, bringing any nested highlights in the list of descriptors
   * forward.
   *
   * This is only supported by independencia (v2-2019) and onwards.
   * For older versions, this will simply do nothing.
   *
   * @typedef HighlightDescriptor
   * @type {object}
   * @property {string} id
   * @property {string} serialisedDescriptor
   *
   * @param {string} id  The id of the deselected highlight.
   * @param {HighlightDescriptor[]} descriptors the serialised highlight descriptors for a set of highlights that could be nested
   *                               in the deselected highlight.
   * @memberof TextHighlighter
   */
  deselectUsingId(id, descriptors) {
    if (this.highlighter.deselectUsingId) {
      this.highlighter.deselectUsingId(id, descriptors);
    } else {
      console.warn(
        `The ${
          versionNames[this.options.version]
        } version of the text highlighter does not support deselecting highlights.`,
      );
    }
  }
}

export default TextHighlighter;