# 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.
       * @returns {HTMLElement}
       */
      static createWrapper(options) {
        return createWrapper(options);
      }

      /**
       * 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 {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,
          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;