highlighters/independencia.js

import {
  retrieveHighlights,
  isElementHighlight,
  sortByDepth,
  findNodesAndOffsets,
  createWrapper,
  createDescriptors,
  getHighlightedTextRelativeToRoot,
  focusHighlightNodes,
  validateIndependenciaDescriptors,
  findHigherPriorityHighlights,
  extractRangeRelativeToRootElement,
} from "../utils/highlights";
import { START_OFFSET_ATTR, LENGTH_ATTR, TIMESTAMP_ATTR } from "../config";
import dom from "../utils/dom";

/**
 * IndependenciaHighlighter that provides text highlighting functionality to dom elements
 * with a focus on removing interdependence between highlights and other element nodes in the context element.
 *
 * @typedef {Object} HlDescriptor
 * @property {string} 0 - The span wrapper injected for the highlight.
 * @property {string} 1 - The highlighted text.
 * @property {number} 2 - The text offset relevant to the root element of a highlight.
 * @property {number} 3 - Length of highlight.
 *
 * @typedef {Object} PreprocessDescriptorsResult
 * @property {HlDescriptor[]} descriptors
 * @property {Object} meta - Any application-specific meta data created in the preprocessing stage that is
 *  used after highlights have been created.
 *
 * @callback PreprocessDescriptors
 * @param {Range} range
 * @param {HlDescriptor[]} highlightDescriptors
 * @param {number} timestamp
 * @return {PreprocessDescriptorsResult}
 *
 * @callback OnAfterHighlightCallbackV2
 * @param {Range} range
 * @param {HlDescriptor[]} highlightDescriptors
 * @param {number} timestamp
 * @param {Object} meta
 */
class IndependenciaHighlighter {
  /**
   * Creates an IndependenciaHighlighter instance for functionality that focuses for highlight independence.
   *
   * @param {HTMLElement} element - DOM element to which highlighted will be applied.
   * @param {object} [options] - additional options.
   * @param {string} options.color - highlight color.
   * @param {string} options.excludeNodes - Node types to exclude when calculating offsets and determining where to inject highlights.
   * @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 {boolean} options.normalizeElements - 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.
   * @param {string} options.highlightedClass - class added to highlight, 'highlighted' by default.
   * @param {string} options.contextClass - class added to element to which highlighter is applied,
   *  'highlighter-context' by default.
   * @param {string} options.namespaceDataAttribute - Data attribute to identify highlights that belong to a particular highlight instance.
   * @param {boolean} options.highlightWhiteSpaceChars - Whether or not to deserialise highlights into the DOM when they only contain white space characters.
   * @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 {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 {PreprocessDescriptors} options.preprocessDescriptors - function called after the user has carried out the action
   *  to trigger creation of highlights after making a text selection. This should be used to customise the highlight span wrapper
   *  with custom data attributes or styles required before the highlight is loaded into the DOM.
   *  This callback must return an array of highlight descriptors.
   * @param {OnAfterHighlightCallbackV2} options.onAfterHighlight - function called after highlight is created. Array of created
   * wrappers is passed as param. This is called after the highlight has been created in the DOM.
   * @class IndependenciaHighlighter
   */
  constructor(element, options) {
    this.el = element;
    this.options = options;
    this.removedHighlights = {};
  }

  /**
   * Highlights current range.
   * @param {boolean} keepRange - Don't remove range after highlighting. Default: false.
   * @memberof IndependenciaHighlighter
   */
  doHighlight(keepRange) {
    let range = dom(this.el).getRange(),
      wrapper,
      timestamp;

    if (!range || range.collapsed) {
      return;
    }

    const rangeRelativeToRootElement = extractRangeRelativeToRootElement(range, this.el);
    if (!rangeRelativeToRootElement) {
      return;
    }

    let eventItems = [];
    dom(this.el).turnOffEventHandlers(eventItems);

    if (this.options.onBeforeHighlight(range) === true) {
      timestamp = +new Date();
      wrapper = createWrapper(this.options, this.el.ownerDocument);
      wrapper.setAttribute(TIMESTAMP_ATTR, timestamp);

      const descriptors = createDescriptors({
        rootElement: this.el,
        range: rangeRelativeToRootElement,
        wrapper,
        excludeNodeNames: this.options.excludeNodes,
        dataAttr: this.options.namespaceDataAttribute,
        excludeWhiteSpaceAndReturns: this.options.excludeWhiteSpaceAndReturns,
      });

      const { descriptors: processedDescriptors, meta } = this.options.preprocessDescriptors(
        rangeRelativeToRootElement,
        descriptors,
        timestamp,
      );
      if (!meta[this.options.cancelProperty]) {
        this.deserializeHighlights(JSON.stringify(processedDescriptors));
        this.options.onAfterHighlight(
          rangeRelativeToRootElement,
          processedDescriptors,
          timestamp,
          meta,
        );
      }
    }

    if (!keepRange) {
      dom(this.el).removeAllRanges();
    }

    dom(this.el).turnOnEventHandlers(eventItems);
  }

  /**
   * Normalizes highlights and the dom. Ensures text nodes within any given element node are merged together, elements with the
   * same ID next to each other are merged together and highlights with the same ID next to each other are merged together.
   *
   * @memberof IndependenciaHighlighter
   */
  normalizeHighlights() {
    dom(this.el).normalizeElements(
      this.options.highlightedClass,
      this.options.namespaceDataAttribute,
    );
  }

  /**
   * Removes one highlight if an ID is provided, removes all highlights in the provided
   * element otherwise.
   *
   * @param {HTMLElement} element - element to remove highlights from
   * @param {string} id - ID of highlight to remove
   * Removes highlights from element using highlight ID.
   * If no id is given, all highlights are removed.
   * @memberof IndependenciaHighlighter
   */
  removeHighlights(element, id) {
    const container = element || this.el;
    let highlights = this.getHighlights({
        container,
        dataAttr: this.options.namespaceDataAttribute,
      }),
      self = this;

    highlights.forEach(function(hl) {
      if (!id || (id && hl.classList.contains(id))) {
        let highlightId = hl.classList.length > 1 ? hl.classList[1] : null;
        if (highlightId && self.removedHighlights[highlightId]) {
          dom(hl).unwrap();
        } else if (self.options.onRemoveHighlight(hl) === true) {
          dom(hl).unwrap();
          if (highlightId) {
            self.removedHighlights[highlightId] = true;
          }
        }
      }
    });

    if (this.options.normalizeElements) {
      this.normalizeHighlights(highlights);
    }
  }

  /**
   * 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 {string} [params.dataAttr] - Namespaced used to identify highlights for a specific highlighter instance.
   * @param {boolean} [params.andSelf] - if set to true and container is a highlight itself, add container to
   * returned results. Default: true.
   * @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 IndependenciaHighlighter
   */
  getHighlights(params) {
    const mergedParams = {
      container: this.el,
      dataAttr: params.dataAttr,
      timestampAttr: TIMESTAMP_ATTR,
      ...params,
    };
    return retrieveHighlights(mergedParams);
  }

  /**
   * Returns true if element is a highlight.
   *
   * @param el - element to check.
   * @param dataAttr - data attribute to determine if the element is a highlight
   * @returns {boolean}
   * @memberof IndependenciaHighlighter
   */
  isHighlight(el, dataAttr) {
    return isElementHighlight(el, dataAttr);
  }

  /**
   * Serializes the highlight belonging to the ID.
   * @param id - ID of the highlight to serialise
   * @returns {string} - stringified JSON with highlights definition
   * @memberof IndependenciaHighlighter
   */
  serializeHighlights(id) {
    const highlights = this.getHighlights({ dataAttr: this.options.namespaceDataAttribute }),
      self = this;

    sortByDepth(highlights, false);

    if (highlights.length === 0) {
      return [];
    }

    let eventItems = [];
    dom(this.el).turnOffEventHandlers(eventItems);

    // Even if there are multiple elements for a given highlight, the first
    // highlight in the DOM with the given ID in it's class name
    // will have all the information we need.
    const highlight = highlights.find((hl) => hl.classList.contains(id));

    if (!highlight) {
      return [];
    }

    const length = highlight.getAttribute(LENGTH_ATTR);
    const offset = highlight.getAttribute(START_OFFSET_ATTR);

    const wrapper = highlight.cloneNode(true);

    wrapper.innerHTML = "";
    const wrapperHTML = wrapper.outerHTML;

    const descriptor = [
      wrapperHTML,
      getHighlightedTextRelativeToRoot(
        {
          rootElement: self.el,
          startOffset: offset,
          length,
          excludeTags: this.options.excludeNodes,
          excludeWhiteSpaceAndReturns: this.options.excludeWhiteSpaceAndReturns,
        },
        this.el.ownerDocument,
      ),
      offset,
      length,
    ];

    dom(this.el).turnOnEventHandlers(eventItems);

    return JSON.stringify([descriptor]);
  }

  /**
   * Deserializes the independent form of 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 IndependenciaHighlighter
   */
  deserializeHighlights(json) {
    let hlDescriptors,
      highlights = [],
      self = this;

    if (!json) {
      return highlights;
    }

    try {
      hlDescriptors = JSON.parse(json);
    } catch (e) {
      throw "Can't parse JSON: " + e;
    }

    let eventItems = [];
    dom(this.el).turnOffEventHandlers(eventItems);

    function deserialise(hlDescriptor) {
      let hl = {
          wrapper: hlDescriptor[0],
          text: hlDescriptor[1],
          offset: Number.parseInt(hlDescriptor[2]),
          length: Number.parseInt(hlDescriptor[3]),
        },
        hlNode,
        highlight;

      const { highlightWhiteSpaceChars } = self.options;
      const parentNode = self.el;
      const { nodesAndOffsets } = findNodesAndOffsets(
        hl,
        parentNode,
        self.options.excludeNodes,
        self.options.excludeWhiteSpaceAndReturns,
      );

      nodesAndOffsets.forEach(({ node, offset: offsetWithinNode, length: lengthInNode }) => {
        const { priorities, namespaceDataAttribute } = self.options;
        const higherPriorityHighlights = findHigherPriorityHighlights(
          parentNode,
          node,
          priorities,
          namespaceDataAttribute,
        );
        // Don't call innerText to prevent DOM layout reflow for every single node,
        // in some cases there can be thousands of nodes subject to highlighting.
        // Visible text content may be a bit of a naive name but represents
        // everything excluding new lines and white space.
        const visibleTextContent = node.textContent.trim().replace(/(\r\n|\n|\r)/gm, "");

        if (
          visibleTextContent.length > 0 ||
          (highlightWhiteSpaceChars && node.textContent.length > 0)
        ) {
          hlNode = node.splitText(offsetWithinNode);
          hlNode.splitText(lengthInNode);

          if (hlNode.nextSibling && !hlNode.nextSibling.nodeValue) {
            dom(hlNode.nextSibling).remove();
          }

          if (hlNode.previousSibling && !hlNode.previousSibling.nodeValue) {
            dom(hlNode.previousSibling).remove();
          }

          // Ensure highlights from higher priority highlighters retain
          // focus by nesting their wrappers.
          higherPriorityHighlights.forEach((otherHighlightNode) => {
            const otherHlNodeCopy = otherHighlightNode.cloneNode(false);
            hlNode = dom(hlNode).wrap(otherHlNodeCopy);
          });
          highlight = dom(hlNode).wrap(dom().fromHTML(hl.wrapper, parentNode.ownerDocument)[0]);

          highlights.push(highlight);
        }
      });
    }

    hlDescriptors.forEach(function(hlDescriptor) {
      try {
        if (validateIndependenciaDescriptors(hlDescriptor)) {
          deserialise(hlDescriptor);
        } else {
          console.warn(
            "Can't deserialize highlight descriptors. Cause: descriptors are not valid.",
          );
        }
      } catch (e) {
        if (console && console.warn) {
          console.warn("Can't deserialize highlight descriptor. Cause: " + e);
        }
      }
    });

    if (this.options.normalizeElements) {
      this.normalizeHighlights();
    }

    dom(this.el).turnOnEventHandlers(eventItems);

    return highlights;
  }

  /**
   * Focuses a highlight, bringing it forward in the case it is sitting behind another
   * overlapping highlight, or a highlight it is nested inside.
   *
   * @param {object} id - The id of the highlight present in the class names of all elements
   *                      in the DOM that represent the highlight.
   *
   * In order to utilise this functionality unique ids for highlights should be added to the class list in the highlight
   * wrapper within the descriptors.
   * You can do this in the onAfterHighlight callback when a highlight is first created.
   *
   * In the future it might be worth adding more flexiblity to allow for user-defined ways of storing ids to identify
   * elements in the DOM. (e.g. choosing between class name or data attributes)
   *
   * @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 IndependenciaHighlighter
   */
  focusUsingId(id, descriptors) {
    const highlightElements = this.el.querySelectorAll(
      `.${id}[${this.options.namespaceDataAttribute}="true"]`,
    );

    let eventItems = [];
    dom(this.el).turnOffEventHandlers(eventItems);

    // For the future, we may save by accepting the offset and length as parameters as the caller should have this data
    // from the serialised descriptors.
    if (highlightElements.length > 0) {
      const firstHighlightElement = highlightElements[0];
      const { nodesAndOffsets } = findNodesAndOffsets(
        {
          offset: Number.parseInt(firstHighlightElement.getAttribute(START_OFFSET_ATTR)),
          length: Number.parseInt(firstHighlightElement.getAttribute(LENGTH_ATTR)),
        },
        this.el,
        this.options.excludeNodes,
        this.options.excludeWhiteSpaceAndReturns,
      );

      const highlightWrapper = firstHighlightElement.cloneNode(true);
      highlightWrapper.innerHTML = "";
      focusHighlightNodes(
        id,
        nodesAndOffsets,
        highlightWrapper,
        this.el,
        this.options.highlightedClass,
        this.options.normalizeElements,
        this.options.priorities,
        this.options.namespaceDataAttribute,
      );
    } else if (descriptors) {
      // No elements in the DOM for the highlight?
      // let's deserialize the descriptor to bring the highlight into focus.
      this.deserializeHighlights(descriptors);
    }

    dom(this.el).turnOnEventHandlers(eventItems);
  }

  /**
   * Deselects a highlight, bringing any nested highlights in the list of descriptors
   * forward.
   *
   * In order to utilise this functionality unique ids for highlights should be added to the class list in the highlight
   * wrapper within the descriptors.
   * You can do this in the onAfterHighlight callback when a highlight is first created.
   *
   * In the future it might be worth adding more flexiblity to allow for user-defined ways of storing ids to identify
   * elements in the DOM. (e.g. choosing between class name or data attributes)
   *
   * @typedef HighlightDescriptor
   * @type {object}
   * @property {string} id
   * @property {string} serialisedDescriptor
   *
   * @param {string} id  The id of the deselected highlight.
   * @param {HighlightDescriptor[]} descriptors An array of serialised descriptors containing all the relevant highlights
   *                               that could be nested within the deselected highlight.
   *
   * @memberof IndependenciaHighlighter
   */
  deselectUsingId(id, descriptors) {
    const deselectedHighlight = this.el.querySelector(`.${id}`);

    if (deselectedHighlight) {
      const deselectedStartOffset = Number.parseInt(
        deselectedHighlight.getAttribute(START_OFFSET_ATTR),
      );
      const deselectedLength = Number.parseInt(deselectedHighlight.getAttribute(LENGTH_ATTR));

      const nestedDescriptors = descriptors
        .map((hlDescriptor) => ({
          id: hlDescriptor.id,
          descriptor: JSON.parse(hlDescriptor.serialisedDescriptor),
        }))
        .filter((hlDescriptor) => {
          const innerDescriptor = hlDescriptor.descriptor[0];
          const offset = Number.parseInt(innerDescriptor[2]);
          const length = Number.parseInt(innerDescriptor[3]);
          return (
            offset >= deselectedStartOffset &&
            offset + length <= deselectedStartOffset + deselectedLength
          );
        });

      nestedDescriptors.sort((a, b) => {
        const aLength = Number.parseInt(a.descriptor[0][3]);
        const bLength = Number.parseInt(b.descriptor[0][3]);
        return aLength > bLength ? -1 : 1;
      });

      nestedDescriptors.forEach((hlDescriptor) => {
        this.focusUsingId(hlDescriptor.id, JSON.stringify(hlDescriptor.descriptor));
      });
    }
  }
}

export default IndependenciaHighlighter;