highlighter.js

const { isWholeWord } = require('./text-methods')

/**
 * Class representing a highlighter. It inserts itself into the dom behind a textarea. It expects
 * the array of tokens to be ordered and to match the order that text appears inside the textarea.
 * Updating the tokens or color works as expected: the highlighter will not be created anew, but
 * will update it's innerHTML content or CSS styles to ensure each token is properly marked for
 * highlighting.
**/
class Highlighter {
  /**
  * Create a Highligher
  *
  * @param {node} textarea - The textarea the highlighter will be attached to
  * @param {string[]} tokens - The tokens (character sequences) to highlight
  * @param {string} color - The 6 digit hexcode color string used for highlighting tokens
  **/
  constructor (textarea, tokens, color) {
    this.$textarea = textarea
    this._text = textarea.value
    this._textareaStyles = window.getComputedStyle(textarea)
    this._tokens = tokens
    this._color = color
    this._handleInput = this._handleInput.bind(this)
    this._handleScroll = this._handleScroll.bind(this)
    this._handleStyles = this._handleStyles.bind(this)
    this.render = this.render.bind(this)
    this.rebuild = this.rebuild.bind(this)
    this.destroy = this.destroy.bind(this)
    this.$highlights = null
    this.$highlighter = null

    this._buildHighlighter()
  }

  get color () {
    return this._color
  }

  set color (color) {
    this._color = color
    this._setHighlightColor()
  }

  get tokens () {
    return this._tokens
  }

  set tokens (tokens) {
    this._tokens = tokens
    this.render()
  }

  // text is copied from textarea to highlighter instance during render
  _handleInput (event) {
    this.render()
  }

  // handle keeping highlight scroll in sync wiht textarea
  _handleScroll (event) {
    if (this.$textarea.scrollTop) {
      this.$highlighter.shadowRoot.querySelector('div').scrollTop = this.$textarea.scrollTop
    }
  }

  // copy desired styles from textarea and reflect them to highlights node
  _handleStyles () {
    const reflectedStyles = [
      'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
      'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
      'font-size', 'font-family', 'font-weight', 'line-height', 'letter-spacing', 'text-align',
      'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'box-sizing',
      'max-height', 'min-height'
    ]
    const geometricStyles = ['width', 'height']
    const offsetStyles = this._offset(this.$textarea, this.$highlighter)

    // only offset top and left values for non static textareas - otherwise highlight layout breaks
    // (tested on Stackoverflow, Github, and Bugzilla forms/comment fields)
    if (this._textareaStyles.getPropertyValue('position') !== 'static') {
      geometricStyles.unshift('top', 'left')
    }

    reflectedStyles.forEach(style => {
      const value = this._textareaStyles.getPropertyValue(style)
      this.$highlights.style[style] = value
    })

    geometricStyles.forEach(style => {
      this.$highlights.style[style] = offsetStyles[style]
    })
  }

  /**
   * _offset - returns the absolute position and inner size of an element
   *
   * @param  {node} targetNode - The element whos absolute position we want to calculate
   * @param  {node} offsetNode - The element whos position we offset by the targetNode
   * @returns {object} - An object containing the size and coordinates measured in pixels
   */
  _offset (targetNode, offsetNode) {
    const targetRect = targetNode.getBoundingClientRect()
    const offsetRect = offsetNode.getBoundingClientRect()

    return {
      top: `${targetRect.top - offsetRect.top}px`,
      left: `${targetRect.left - offsetRect.left}px`,
      width: `${targetNode.clientWidth}px`,
      height: `${targetNode.clientHeight}px`
    }
  }

  /**
   * _isWholeWord - private wrapper of isWholeWord
   *
   * @see TextMethods.isWholeWord
   */
  _isWholeWord (word, content, start = 0) {
    return isWholeWord(word, content, start)
  }

  // appends a mark to the end of a node
  _appendMark (node, token) {
    const mark = document.createElement('mark')
    mark.classList.add('color')
    mark.textContent = token
    node.appendChild(mark)
  }

  // appends a chunk of text to a node
  _appendText (node, text, start, end) {
    node.appendChild(document.createTextNode(text.slice(start, end)))
  }

  // generates mark tags for each token found within the current text content
  _generateMarks () {
    let text = this._text
    let searchIndex = 0
    let previousIndex = 0
    let done = false
    let i = 0

    const fragment = new DocumentFragment()

    while (!done) {
      const token = this._tokens[i]
      // if there are no more tokens, we are done highlighting
      if (i === this._tokens.length) {
        done = true
        continue
      }

      // skip current token if we don't find it inside remaining text
      if (text.indexOf(token, searchIndex) === -1) {
        i++
        continue
      }

      searchIndex = text.indexOf(token, searchIndex)
      this._appendText(fragment, text, previousIndex, searchIndex)
      if (this._isWholeWord(token, text, searchIndex)) {
        this._appendMark(fragment, token)
        i++
      } else {
        this._appendText(fragment, text, searchIndex, searchIndex + token.length)
      }
      searchIndex += token.length
      previousIndex = searchIndex
    }

    // this keeps the highlights aligned if textarea ends with a new line
    text = text.replace(/\n$/, '\n\n')

    while (this.$highlights.firstChild) this.$highlights.removeChild(this.$highlights.firstChild)
    this.$highlights.appendChild(fragment)
  }

  // this will update the currently displayed highlight color only (the original innerHTML will
  // still reference the color used to instantiate the class)
  _setHighlightColor () {
    const marks = this.$highlighter.shadowRoot.querySelectorAll('mark')
    marks.forEach(mark => { mark.style.backgroundColor = this.color })
  }

  // return the template used for building the highlighter
  _getInnerHtmlTemplate () {
    return `
    <style>
    :host(div) {
      position: absolute;
    }
    #highlights {
      background: none transparent;
      border-color: transparent;
      color: transparent;
      pointer-events: none;
      position: absolute;
      overflow: hidden;
      white-space: pre-wrap;
      z-index: 1;
    }
    mark {
      border-radius: 0.5em;
      background-color: ${this.color}33;
      color: transparent;
      opacity: 35%;
    }
    mark.color{
      background-color: ${this.color};
    }
    </style>

    <div id="highlights"></div>
    `
  }

  // attach event listeners to the textarea that we are monitoring for changes and insert
  // highlighter into DOM, using shadow dom to style and encapsulate it
  _buildHighlighter () {
    this.$highlighter = document.createElement('div')
    this.$highlighter.attachShadow({ mode: 'open' })
    this.$highlighter.setAttribute('class', 'data-multidict-highlights')
    this.$highlighter.shadowRoot.innerHTML = this._getInnerHtmlTemplate()
    this.$highlights = this.$highlighter.shadowRoot.querySelector('#highlights')
    this.$textarea.setAttribute('data-multidict-current', true)
    this.$textarea.addEventListener('input', this._handleInput)
    this.$textarea.addEventListener('scroll', this._handleScroll)
    this.$textarea.insertAdjacentElement('beforebegin', this.$highlighter)

    this._observer = new MutationObserver((mutations) => {
      mutations.forEach((mutationRecord) => {
        this._handleStyles()
      })
    })

    // observe all style changes of textarea
    this._observer.observe(this.$textarea, { attributes: true, attributeFilter: ['style'] })

    this.render()
  }

  destroy () {
    this._observer.disconnect()
    this.$textarea.removeAttribute('data-multidict-current')
    this.$textarea.removeEventListener('input', this._handleInput)
    this.$textarea.removeEventListener('scroll', this._handleScroll)
    this.$highlighter.remove()
  }

  // rebuild the innerHTML content of the highlighter (for when color is out of sync)
  rebuild () {
    this.$highlighter.shadowRoot.innerHTML = this._getInnerHtmlTemplate()
    this.$highlights = this.$highlighter.shadowRoot.querySelector('#highlights')
    this.render()
  }

  // renders the highlighter in full (generates marks, sets highlight color)
  render () {
    this._text = this.$textarea.value
    this._handleStyles()
    this._generateMarks()
    this._handleScroll()
  }
}

module.exports = { Highlighter }