smartguide/SmartGuide.js

import PerfectScrollbar from 'perfect-scrollbar'
import Clipboard from 'clipboard'
import Constants from '../configuration/Constants'

/**
 * Smart guide
 * @typedef {Object} SmartGuide
 * @property {Editor} editor - A reference to the current editor.
 * @property {String} wordToChange - Word to change following a click on a word.
 * @property {String} lastWord - Keep the last word of the previous export to compare with the new and scroll if it's different.
 * @property {String} previousLabelExport - Keep the previous label export to know if we should repopulate the prompter text.
 * @property {PerfectScrollbar} perfectScrollbar - Perfect Scrollbar used to get gestures from smart guide using touch-action none anyway and get scrolling too.
 * @property {Object} elements - All the HTML elements of the smart guide.
 * @property {Number} smartGuideTimeOutId - Id of the setTimeOut from fade out animation to clear.
 * @property {String} randomString - Random string used in case of multiple smart guide.
 */

/**
 * Create all the smart guide HTML elements.
 */
function createHTMLElements (randomString) {
  /**
   * The smart guide element.
   * @type {HTMLDivElement}
   */
  const smartGuideElement = document.createElement('div')
  smartGuideElement.id = 'smartguide' + randomString
  smartGuideElement.classList.add('smartguide')

  /**
   * The prompter text element that contains the text to get the overflow working.
   * @type {HTMLDivElement}
   */
  const textElement = document.createElement('div')
  textElement.id = 'prompter-text' + randomString
  textElement.classList.add('prompter-text')
  textElement.setAttribute('touch-action', 'none')

  /**
   * The text container element that contains the text element.
   * @type {HTMLDivElement}
   */
  const textContainer = document.createElement('div')
  textContainer.id = 'prompter-text-container' + randomString
  textContainer.classList.add('prompter-text-container')
  textContainer.appendChild(textElement)

  /**
   * The actions menu represented by the ellipsis character.
   * @type {HTMLDivElement}
   */
  const ellipsisElement = document.createElement('div')
  ellipsisElement.id = 'ellipsis' + randomString
  ellipsisElement.classList.add('ellipsis')
  ellipsisElement.innerHTML = '...'

  /**
   * The tag element.
   * @type {HTMLDivElement}
   */
  const tagElement = document.createElement('div')
  tagElement.id = 'tag-icon' + randomString
  tagElement.classList.add('tag-icon')
  tagElement.innerHTML = '¶'

  /**
   * The candidates element that contains the candidates for a word.
   * @type {HTMLDivElement}
   */
  const candidatesElement = document.createElement('div')
  candidatesElement.id = 'candidates' + randomString
  candidatesElement.classList.add('candidates')

  /**
   * The menu element that contains the actions.
   * @type {HTMLDivElement}
   */
  const menuElement = document.createElement('div')
  menuElement.id = 'more-menu' + randomString
  menuElement.classList.add('more-menu')

  /**
   * The convert button from actions menu.
   * @type {HTMLButtonElement}
   */
  const convertElement = document.createElement('button')
  convertElement.classList.add('options-label-button')
  convertElement.id = 'convert' + randomString
  convertElement.innerHTML = 'Convert'

  /**
   * The copy button from actions menu.
   * @type {HTMLButtonElement}
   */
  const copyElement = document.createElement('button')
  copyElement.classList.add('options-label-button')
  copyElement.id = 'copy' + randomString
  copyElement.innerHTML = 'Copy'

  /**
   * The delete button from actions menu.
   * @type {HTMLButtonElement}
   */
  const deleteElement = document.createElement('button')
  deleteElement.classList.add('options-label-button')
  deleteElement.id = 'delete' + randomString
  deleteElement.innerHTML = 'Delete'

  return {
    smartGuideElement,
    textElement,
    textContainer,
    candidatesElement,
    menuElement,
    tagElement,
    ellipsisElement,
    convertElement,
    copyElement,
    deleteElement
  }
}

/**
 * Check if node is in shadow dom
 * @param {Node} node - A node element.
 * @returns {boolean} true if is in shadow dom, false otherwise.
 */
function isInShadow (node) {
  let parent = (node && node.parentNode)
  while (parent) {
    if (parent.toString() === '[object ShadowRoot]') {
      return true
    }
    parent = parent.parentNode
  }
  return false
}

/**
 * Show the actions of the action menu.
 * @param {Event} evt - Event used to insert the option div using the event's target.
 * @param {Object} elements - All the elements of the smart guide.
 * @param {SmartGuide} smartGuide
 */
function showActions (evt, elements) {
  const elementsRef = elements

  const insertActions = () => {
    elementsRef.menuElement.appendChild(elementsRef.convertElement)
    elementsRef.menuElement.appendChild(elementsRef.copyElement)
    elementsRef.menuElement.appendChild(elementsRef.deleteElement)

    const parent = evt.target.parentNode
    parent.insertBefore(elementsRef.menuElement, evt.target)
  }

  const positionActions = () => {
    // 48 to get the boundary of smart guide element.
    const left = evt.target.offsetLeft - 68
    elementsRef.menuElement.style.left = `${left}px`
  }

  const isMenuInDocument = document.contains(elementsRef.menuElement)
  if (!isInShadow(elementsRef.menuElement) && !isMenuInDocument) {
    elementsRef.menuElement.style.display = 'flex'
    positionActions()
    insertActions()
  } else if (elementsRef.menuElement.style.display === 'none') {
    positionActions()
    elementsRef.menuElement.style.display = 'flex'
  }
}

/**
 * Show the candidates of the clicked word.
 * @param {Event} evt - Event used to determine the clicked word.
 * @param {Editor} editor - A reference to the editor.
 * @param {SmartGuide} smartGuide - A reference to the smart guide.
 */
function showCandidates (evt, editor, smartGuide) {
  const smartGuideRef = smartGuide
  const elementsRef = smartGuide.elements

  if (evt.target.id !== `prompter-text${smartGuide.randomString}`) {
    const id = evt.target.id.replace('word-', '').replace(smartGuide.randomString, '')
    const words = JSON.parse(editor.exports[Constants.Exports.JIIX]).words
    smartGuideRef.wordToChange = words[id]
    smartGuideRef.wordToChange.id = id
    elementsRef.candidatesElement.innerHTML = ''
    if (smartGuideRef.wordToChange && smartGuideRef.wordToChange.candidates) {
      elementsRef.candidatesElement.style.display = 'flex'
      smartGuideRef.wordToChange.candidates.forEach((word, index) => {
        if (smartGuideRef.wordToChange.label === word) {
          elementsRef.candidatesElement.innerHTML += `<span id="cdt-${index}${smartGuide.randomString}" class="selected-word">${word}</span>`
        } else {
          elementsRef.candidatesElement.innerHTML += `<span id="cdt-${index}${smartGuide.randomString}">${word}</span>`
        }
      })
      // get the parent parent of word to insert just before smart guide, 48 to get the boundary of smart guide element.
      const top = 48
      const left = evt.target.getBoundingClientRect().left - 60
      elementsRef.candidatesElement.style.top = `${top}px`
      elementsRef.candidatesElement.style.left = `${left}px`

      const parent = evt.target.parentNode.parentNode.parentNode
      parent.insertBefore(elementsRef.candidatesElement, evt.target.parentNode.parentNode)
    }
  }
}

/**
 * Call the import_ function of the editor to import the modified Jiix with the new label.
 * @param {Event} evt - Event to determine the clicked candidate.
 * @param {Editor} editor - A reference to the editor.
 * @param {SmartGuide} smartGuide - A reference to the smart guide.
 */
function clickCandidate (evt, editor, smartGuide) {
  const smartGuideRef = smartGuide
  const elementsRef = smartGuide.elements
  const candidate = evt.target.innerText

  if (candidate !== smartGuideRef.wordToChange.label && smartGuideRef.wordToChange.candidates.includes(candidate)) {
    const jiixToImport = JSON.parse(editor.exports[Constants.Exports.JIIX])
    jiixToImport.words[smartGuideRef.wordToChange.id].label = candidate
    // eslint-disable-next-line no-underscore-dangle
    editor.import_(JSON.stringify(jiixToImport), Constants.Exports.JIIX)
  }
  elementsRef.candidatesElement.style.display = 'none'
}

/**
 * Add the listeners to the smart guide elements.
 * @param {Editor} editor - A reference to the editor.
 * @param {SmartGuide} smartGuide - A reference to the smart guide.
 */
function addListeners (editor, smartGuide) {
  const elementsRef = smartGuide.elements

  elementsRef.textElement.addEventListener('click', evt => showCandidates(evt, editor, smartGuide))
  elementsRef.candidatesElement.addEventListener('click', evt => clickCandidate(evt, editor, smartGuide))
  elementsRef.ellipsisElement.addEventListener('click', evt => showActions(evt, elementsRef))
  elementsRef.copyElement.addEventListener('click', () => {
    elementsRef.menuElement.style.display = 'none'
  })
  elementsRef.convertElement.addEventListener('click', () => {
    elementsRef.menuElement.style.display = 'none'
    editor.convert()
  })
  elementsRef.deleteElement.addEventListener('click', () => {
    elementsRef.menuElement.style.display = 'none'
    editor.clear()
  })
}

/**
 * Call mutation observer to trigger fade out animation.
 * @param {number} [duration=10000] - the duration in milliseconds before calling the fade out animation.
 * @param {SmartGuide} smartGuide - A reference to the smart guide.
 */
function callFadeOutObserver (duration = 10000, smartGuide) {
  const smartGuideRef = smartGuide
  const elementsRef = smartGuide.elements
  // eslint-disable-next-line no-undef
  const observer = new MutationObserver((mutations) => {
    mutations.forEach(() => {
      if (smartGuideRef.smartGuideTimeOutId) {
        clearTimeout(smartGuideRef.smartGuideTimeOutId)
      }
      if (elementsRef.candidatesElement.style.display === 'none' && elementsRef.menuElement.style.display === 'none') {
        smartGuideRef.smartGuideTimeOutId = setTimeout(() => {
          elementsRef.smartGuideElement.classList.add('smartguide-out')
          elementsRef.smartGuideElement.classList.remove('smartguide-in')
        }, duration)
      } else if (!document.contains(elementsRef.candidatesElement) && !document.contains(elementsRef.menuElement)) {
        smartGuideRef.smartGuideTimeOutId = setTimeout(() => {
          elementsRef.smartGuideElement.classList.add('smartguide-out')
          elementsRef.smartGuideElement.classList.remove('smartguide-in')
        }, duration)
      }
    })
  })
  observer.observe(elementsRef.smartGuideElement, { childList: true, subtree: true, attributes: true })
}

/**
 * Create a new smart guide
 * @param {Editor} editor - A reference to the editor.
 * @returns {SmartGuide} New smart guide
 */
export function createSmartGuide (editor) {
  const randomString = '-' + Math.random().toString(10).substring(2, 12)
  const elements = createHTMLElements(randomString)

  /**
   * Clipboard from clipboard.js used to get copy across all browsers.
   * @type {Clipboard}
   */
  // eslint-disable-next-line no-unused-vars
  const clipboard = new Clipboard(elements.copyElement)
  const perfectScrollbar = new PerfectScrollbar(elements.textContainer, { suppressScrollY: true, scrollXMarginOffset: 1 })

  const smartGuide = {
    editor,
    wordToChange: '',
    lastWord: '',
    previousLabelExport: ' ',
    perfectScrollbar,
    elements,
    smartGuideTimeOutId: 0,
    randomString
  }
  addListeners(editor, smartGuide)

  if (editor.configuration.recognitionParams.iink.text.smartGuideFadeOut.enable) {
    callFadeOutObserver(editor.configuration.recognitionParams.iink.text.smartGuideFadeOut.duration, smartGuide)
  }

  return smartGuide
}

export function resize (smartGuide) {
  const smartGuideRef = smartGuide
  const elementsRef = smartGuide.elements

  const mmToPixels = 3.779527559
  let left = smartGuideRef.editor.configuration.recognitionParams.iink.text.margin.left * mmToPixels

  const maxWidthTextContainer = smartGuideRef.editor.domElement.clientWidth - left - elementsRef.tagElement.offsetWidth - 35 - left

  // Assign a max width to the smartguide based on the editor width, the left position and a small margin for the ellipsis (48px)
  elementsRef.textContainer.style.width = `${maxWidthTextContainer}px`
  elementsRef.textContainer.style.maxWidth = `${maxWidthTextContainer}px`

  left = elementsRef.tagElement.offsetWidth
  left += maxWidthTextContainer
  elementsRef.ellipsisElement.style.left = `${left}px`

  elementsRef.smartGuideElement.style.width = `${elementsRef.tagElement.offsetWidth + elementsRef.textContainer.offsetWidth + elementsRef.ellipsisElement.offsetWidth}px`
  smartGuideRef.perfectScrollbar.update()
}

/**
 * Insert the smart guide HTML elements in the DOM.
 * @param {SmartGuide} smartGuide - A reference to the smart guide.
 */
export function insertSmartGuide (smartGuide) {
  const smartGuideRef = smartGuide
  const elementsRef = smartGuide.elements

  const insertSmartGuideElement = (left, top) => {
    elementsRef.smartGuideElement.style.top = `${top}px`
    elementsRef.smartGuideElement.style.left = `${left}px`
    elementsRef.smartGuideElement.style.visibility = 'hidden'

    const parent = smartGuideRef.editor.domElement
    parent.insertBefore(elementsRef.smartGuideElement, smartGuideRef.editor.loader)
  }
  const insertTag = () => {
    elementsRef.smartGuideElement.appendChild(elementsRef.tagElement)
  }
  const insertTextContainer = (left, maxWidth) => {
    elementsRef.textContainer.style.left = `${left}px`

    // Assign a max width to the smartguide based on the editor width, the left position and a small margin for the ellipsis (48px)
    elementsRef.textContainer.style.width = `${maxWidth}px`
    elementsRef.textContainer.style.maxWidth = `${maxWidth}px`

    elementsRef.smartGuideElement.appendChild(elementsRef.textContainer)
  }
  const insertEllipsis = (left) => {
    elementsRef.ellipsisElement.style.left = `${left}px`

    elementsRef.smartGuideElement.appendChild(elementsRef.ellipsisElement)
  }

  // FIXME Use value from contentChanged when available

  const mmToPixels = 3.779527559

  const marginTop = smartGuideRef.editor.configuration.recognitionParams.iink.text.margin.top * mmToPixels
  const marginLeft = smartGuideRef.editor.configuration.recognitionParams.iink.text.margin.left * mmToPixels

  // 12 is the space between line in mm
  const top = marginTop - (12 * mmToPixels)
  let left = marginLeft

  insertSmartGuideElement(left, top)
  insertTag()

  // 35 is the ellipsis element width
  const maxWidthTextContainer = smartGuideRef.editor.domElement.clientWidth - left - elementsRef.tagElement.offsetWidth - 35 - left
  left = elementsRef.tagElement.offsetWidth
  insertTextContainer(left, maxWidthTextContainer)

  left += maxWidthTextContainer
  insertEllipsis(left)

  elementsRef.menuElement.style.display = 'none'
  elementsRef.menuElement.appendChild(elementsRef.convertElement)
  elementsRef.menuElement.appendChild(elementsRef.copyElement)
  elementsRef.menuElement.appendChild(elementsRef.deleteElement)
  elementsRef.smartGuideElement.appendChild(elementsRef.menuElement)

  elementsRef.candidatesElement.style.display = 'none'
  elementsRef.smartGuideElement.appendChild(elementsRef.candidatesElement)

  // 48px as set in css
  elementsRef.smartGuideElement.style.height = '48px'
  elementsRef.smartGuideElement.style.width = `${elementsRef.tagElement.offsetWidth + elementsRef.textContainer.offsetWidth + elementsRef.ellipsisElement.offsetWidth}px`
  smartGuideRef.perfectScrollbar.update()
}

/**
 * Launch the smartguide.
 * @param {SmartGuide} smartGuide - A reference to the smart guide.
 * @param {Object} exports -  The export from the editor.
 */
export function launchSmartGuide (smartGuide, exports) {
  const smartGuideRef = smartGuide
  const elementsRef = smartGuide.elements

  const isSmartGuideInDocument = document.contains(elementsRef.smartGuideElement)

  if (!isInShadow(elementsRef.smartGuideElement) && !isSmartGuideInDocument) {
    insertSmartGuide(smartGuide)
  }

  const addAnimationToModifiedWord = (words) => {
    if (smartGuideRef.tempWords && smartGuideRef.tempWords.length === words.length) {
      const labelWordsArray = words.map(word => word.label)
      const tempLabelWordsArray = smartGuideRef.tempWords.map(word => word.label)
      const wordChangedId = labelWordsArray.indexOf(labelWordsArray.filter(a => tempLabelWordsArray.indexOf(a) === -1)[0])
      if (document.getElementById(`word-${wordChangedId}${smartGuide.randomString}`) && wordChangedId > -1) {
        document.getElementById(`word-${wordChangedId}${smartGuide.randomString}`).classList.add('modified-word')
        elementsRef.textContainer.scrollLeft = document.getElementById(`word-${wordChangedId}${smartGuide.randomString}`).offsetLeft - 10
      }
    }
    smartGuideRef.tempWords = JSON.parse(exports[Constants.Exports.JIIX]).words
  }

  const createWordSpan = (empty, index, word) => {
    const span = document.createElement('span')
    span.id = `word-${index}${smartGuide.randomString}`
    if (empty) {
      span.innerHTML = '&nbsp;'
    } else {
      span.textContent = word.label
    }
    return span
  }

  // Possible optimisation ? Check if we can find a way to not repopulate the smartguide every time even if we now use Document fragment
  const populatePrompter = (words) => {
    elementsRef.textElement.innerHTML = ''
    // We use a DocumentFragment to reflow the DOM only one time as it is not part of the DOM
    const myFragment = document.createDocumentFragment()
    words.forEach((word, index) => {
      if (word.label === ' ' || word.label.includes('\n')) {
        myFragment.appendChild(createWordSpan(true, index))
      } else if (index !== words.length - 1) {
        myFragment.appendChild(createWordSpan(false, index, word))
      } else {
        elementsRef.textElement.appendChild(myFragment)
        smartGuideRef.perfectScrollbar.update()
        if (smartGuideRef.lastWord === '') {
          smartGuideRef.lastWord = word
        }
        const span = createWordSpan(false, index, word)
        // This is used to scroll to last word if last word is modified
        if ((smartGuideRef.lastWord.candidates !== word.candidates) && (smartGuideRef.lastWord.label !== word.label)) {
          span.classList.add('added-word')
          elementsRef.textElement.appendChild(span)
          elementsRef.textContainer.scrollLeft = span.offsetLeft
          smartGuideRef.lastWord = word
        } else {
          elementsRef.textElement.appendChild(span)
          elementsRef.textContainer.scrollLeft = span.offsetLeft
        }
      }
    })
  }

  if (exports && JSON.parse(exports[Constants.Exports.JIIX]).words.length > 0) {
    elementsRef.smartGuideElement.classList.add('smartguide-in')
    elementsRef.smartGuideElement.classList.remove('smartguide-out')
    elementsRef.candidatesElement.style.display = 'none'
    elementsRef.menuElement.style.display = 'none'
    if (smartGuideRef.previousLabelExport && smartGuideRef.previousLabelExport !== JSON.parse(exports[Constants.Exports.JIIX]).label) {
      const words = JSON.parse(exports[Constants.Exports.JIIX]).words
      populatePrompter(words)
      addAnimationToModifiedWord(words)
    }
    smartGuideRef.previousLabelExport = JSON.parse(exports[Constants.Exports.JIIX]).label
    // This is required by clipboard.js to get the text to be copied.
    elementsRef.copyElement.setAttribute('data-clipboard-text', JSON.parse(exports[Constants.Exports.JIIX]).label)
  } else {
    elementsRef.smartGuideElement.classList.add('smartguide-out')
    elementsRef.smartGuideElement.classList.remove('smartguide-in')
  }

  return smartGuideRef
}