legacy/outils/menuContextuel/index.js

import { j3pAddElt, j3pDetruit, j3pElement } from 'src/legacy/core/functions'
import 'src/legacy/outils/menuContextuel/menuContextuel.css'
import { getJ3pConteneur } from 'src/lib/core/domHelpers'
import { j3pAffiche } from 'src/lib/mathquill/functions'
import { isHtmlElement } from 'src/lib/utils/dom/main'
/**
 * Une callback de menu, son this sera l’élément cliqué
 * @callback MenuContextuelCallback
 * @param {MenuContextuel} menu L’instance courante du menu
 * @param {MenuContextuelChoice} choice Le choix sur lequel on a cliqué (tel qu’on l’a passé au constructeur)
 * @param {Event} event L’événement click
 */

/**
 * @typedef MenuContextuelChoice
 * @type Object
 * @property {number} i
 * @property {HTMLElement} elt
 * @property {string} [label]
 * @property {MenuContextuelCallback} [callback]
 */

/**
 * Menu contextuel qui apparaît au clic sur un élément
 * @class MenuContextuel
 * @constructor
 */

const dummyFn = () => {}

/**
 *
 * @param trigger
 * @param choices
 * @param {Object} props
 * @param {Object} [props.style]
 * @param {Array} props.infos à documenter
 * @param {string} props.id id à mettre sur le div qui contiendra les éléments de menu
 * @param {Object} options
 * @param {boolean} [options.stayOpenOnClick=false] Passer true pour ne pas fermer le menu à chaque clic sur un de ses éléments
 * @param {boolean} [options.pasToucheBackground=false] Passer true pour ne pas toucher au background du trigger
 * @param {boolean} [options.colle=false] Passer true que le menu soit collé au trigger
 * @param {boolean} [options.callBackOpen = () => {}] function appelée à l’ouverture du menu
 * @param {boolean} [options.callBackClose = () => {}] function appelée à la fermeture du menu
 * @param {HTMLElement} [options.j3pCont] Pour imposer le conteneur (sinon on utilise getJ3pConteneur() ou on prend .j3pContainer)
 * @constructor
 */
function MenuContextuel (trigger, choices, props, options) {
  if (typeof this !== 'object') throw Error('Constructeur qui doit être appelé avec new')
  // retourne un listener prêt pour addEventListener, pour les clics sur les éléments de menu
  function getListener (callback, index) {
    return function (event) {
      // on ferme le menu
      if (!options.stayOpenOnClick) menu.hide()
      // on applique notre this (l’élément) à la callback, et on lui passe le reste en argument
      callback.call(this, menu, choices[index], event)
    }
  }
  // retourne true si on est un elt du trigger ou du menu (pour que le window.onClick ne fasse rien)
  function isMenuChild (elt) {
    while (elt) {
      if (elt === menu.container || elt === menu.trigger) return true
      elt = elt.parentNode
    }
    return false
  }

  const menu = this
  if (!props) props = {}
  if (!props.style) props.style = {}
  if (!options) options = {}
  this.callBackOpen = options.callBackOpen || dummyFn
  this.callBackClose = options.callBackClose || dummyFn

  this.pasToucheBackground = Boolean(options.pasToucheBackground === true)
  this.dep = (options.colle === true) ? 0 : 3
  /**
   * @todo À documenter et typer
   * @type {*}
   */
  this.infos = props.infos
  /**
   * L’élément sur lequel on va ajouter notre listener onClick
   * @type {HTMLElement|string}
   */
  this.trigger = (typeof trigger === 'string') ? j3pElement(trigger) : trigger
  if (!isHtmlElement(trigger)) return console.error(Error('Pas de trigger (ou trigger invalide), impossible de créer un menu contextuel'))
  this.triggerBgColorDefault = getComputedStyle(this.trigger).backgroundColor

  this.mepact = getJ3pConteneur(trigger, true) || j3pElement('j3pContainer') || options.j3pCont
  if (!this.mepact) throw Error('Pas trouvé de conteneur')
  if (this.mepact.URL) this.mepact = j3pElement('j3pContainer') || options.j3pCont

  const { height: trigH, x: trigX, y: trigY } = trigger.getBoundingClientRect()
  const { x: mainX, y: mainY } = this.mepact.getBoundingClientRect()
  // on crée un div en absolute positionné juste dessous
  props.style.display = 'none'
  props.style.position = 'absolute'
  props.style.left = (trigX - mainX) + 'px'
  props.style.top = (trigY - mainY + trigH + 3) + 'px'
  this.container = j3pAddElt(this.mepact, 'div', '', props)
  // on le fait juste après (et pas imposé dans les props) au cas où on nous aurait déjà passé des classes css
  this.container.classList.add('menuContextuel')
  // et on ajoute les éléments dedans
  let elt
  const labelProps = {
    style: {
      position: 'relative'
    }
  }
  /**
   * Liste des choix
   * @type {MenuContextuelChoice[]}
   */
  this.listeChoicesMenu = []
  const objet = this
  for (const [i, choice] of choices.entries()) {
    // attention, dans ce forEach on a perdu this
    // check
    if (typeof choice !== 'object' || typeof choice.label !== 'string' || typeof choice.callback !== 'function') {
      console.error(Error('choix invalide'), choice)
      continue
    }
    // ok, on ajoute l’élément
    if (/\$[^$]+\$/.test(choice.label)) {
      // en 2 temps pour passer j3pAffiche sur le label
      elt = j3pAddElt(menu.container, 'div', null, labelProps)
      j3pAffiche(elt, '', choice.label)
    } else {
      elt = j3pAddElt(menu.container, 'div', choice.label, labelProps)
    }
    // ici on n’a pas besoin de référencer les listeners qu’on ajoute car on aura pas besoin de les retirer
    // (à la destruction du menu js les vire avec leur élément support)
    // Sinon il faudrait mettre les résultats de getListener dans une variable.
    elt.addEventListener('click', getListener(choice.callback, i))
    objet.listeChoicesMenu.push({ i, elt })
  }
  // un booléen pour désactiver les callback
  this.isActive = true
  // nos listeners
  this.onClickMenu = this.toggle.bind(this)
  // clic sur le déclencheur
  this.trigger.addEventListener('click', this.onClickMenu)
  // un écouteur sur window pour refermer le menu au clic en dehors
  this.onClickWindow = function (event) {
    // this est ici l’élément sur lequel on a mis l’écouteur
    // event.target l’élément sur lequel on a cliqué
    if (isMenuChild(event.target)) return
    // on a cliqué sur autre chose qu’un item du menu => on ferme le menu
    menu.hide()
  }
  window.addEventListener('click', this.onClickWindow)
  // et un écouteur sur le resize pour repositionner notre div en absolu
  this.onResize = this.replace.bind(this)
  window.addEventListener('resize', this.onResize)
}

MenuContextuel.prototype.show = function () {
  if (!this.container) return console.error(Error('Ce menu a été détruit'))
  // on replace le menu au cas où le trigger aurait changé de place
  this.replace()
  this.container.style.display = 'block'
  if (!this.pasToucheBackground) this.trigger.style.backgroundColor = '#ddf' // cf css .menuContextuel div:hover
  this.callBackOpen()
}

MenuContextuel.prototype.hide = function () {
  if (!this.container) return console.error(Error('Ce menu a été détruit'))
  this.container.style.display = 'none'
  if (!this.pasToucheBackground) this.trigger.style.backgroundColor = this.triggerBgColorDefault
  this.callBackClose()
}

/**
 * Affiche ou masque le menu
 */
MenuContextuel.prototype.toggle = function () {
  if (!this.isActive) return
  this.isHidden() ? this.show() : this.hide()
}

/**
 * Détruit le menu (qui ne peut plus être utilisé ensuite),
 * Il faut supprimer les variables qui pointent dessus pour que le js puisse le supprimer en RAM.
 */
MenuContextuel.prototype.destroy = function () {
  if (!this.container) return console.error(Error('Ce menu a été détruit'))
  this.trigger.removeEventListener('click', this.onClickMenu)
  window.removeEventListener('click', this.onClickWindow)
  window.removeEventListener('resize', this.onResize)
  j3pDetruit(this.container)
  this.container = null
}

MenuContextuel.prototype.isHidden = function () {
  if (!this.container) return console.error(Error('Ce menu a été détruit'))
  return this.container.style.display === 'none'
}
MenuContextuel.prototype.setActive = function (isActive) {
  if (!this.container) return console.error(Error('Ce menu a été détruit'))
  this.isActive = Boolean(isActive)
}
/**
 * À appeler si le trigger qu’on avait passé au constructeur a changé de place
 */
MenuContextuel.prototype.replace = function () {
  if (!this.container) return console.error(Error('Ce menu a été détruit'))
  const triggerPos = this.trigger.getBoundingClientRect()
  const { x: mainX, y: mainY } = this.mepact.getBoundingClientRect()
  // on crée un div en absolute positionné juste dessous
  this.container.style.left = (triggerPos.x - mainX) + 'px'
  this.container.style.top = (triggerPos.y - mainY + triggerPos.height + this.dep) + 'px'
}

export default MenuContextuel