lib/widgets/mqVirtualKeyboard/MqVirtualKeyboard.js

/* global MathQuill */
import $ from 'jquery'
import { j3pAddElt, j3pIsHtmlElement, j3pValeurde } from 'src/legacy/core/functions'
import { getZoneParente } from 'src/lib/core/domHelpers'
import Button from 'src/lib/widgets/mqVirtualKeyboard/Button'
import ButtonArrow from 'src/lib/widgets/mqVirtualKeyboard/ButtonArrow'
import ButtonBackSpace, { doBackSpaceAction } from 'src/lib/widgets/mqVirtualKeyboard/ButtonBackSpace'
import ButtonEnter, { doEnterAction } from 'src/lib/widgets/mqVirtualKeyboard/ButtonEnter'
import ButtonMq, { doCharAction } from 'src/lib/widgets/mqVirtualKeyboard/ButtonMq'
import ButtonMqCommand from 'src/lib/widgets/mqVirtualKeyboard/ButtonMqCommand'

import './mqVirtualKeyboard.scss'

const decalageSousInputMq = 4
// la marge qu’on se réserve dans le boundingContainer dans lequel on essaie de rester
const boundingMargin = 20 // en dessous de 20 ça déborde sous l’éventuel scrollbar verticale

// const logEvent = (event, ...args) => {
//   console.log('event', event.type, 'on', event.target.id || Array.from(event.target.classList).join(' ') || event.target)
//   if (args.length) console.log.apply(console, args)
// }

// Pour gérer une touche Tab ou Shift-Tab, on regarde la position du curseur Mathquill, on envoie
// la commande MathQuill correspondante et on regarde la position finale du curseur
// Si ces deux positions ne sont pas égales, on ne fait pas remonter l’événenemnt
// Si elles sont égales c’est que la position du curseur n’a pas changé et on transmet
// la commande Tab ou Shift-Tab
// A noter qu’il faut gérer les touches ici mais pas dans keyup car dans FireFox
// keyup n’est pas généré si on tape par exemple sur la touche /
const keydownListener = (event) => {
  // on écoute rien si :
  if (
    // y’a pas de clavier
    !currentVirtualKeyboard ||
    // il est masqué
    !currentVirtualKeyboard.isOpen ||
    // on a marqué l’événement clavier comme devant être ignoré
    event.isFake ||
    // Yves : si l’éditeur associé est caché ou enfant d’une élément du DOM caché
    // cf https://developer.mozilla.org/fr/docs/Web/API/HTMLElement/offsetParent
    // ici currentVirtualKeyboard est bien positionné, donc s’il est masqué alors son parent positionné le plus proche sera null
    !currentVirtualKeyboard.inputMq.offsetParent
  ) {
    return
  }
  const activeElt = document.activeElement
  if (
    activeElt && // y’a un élément actif (dans le cas aucun, y’a eu un blur, on écoute quand même)
    activeElt !== document.body && // c’est pas le body (car avec le clavier virtuel qui simule le focus activeElt peut être le body)
    !activeElt.classList.contains('mq-editable') && // pas un inputMq
    !activeElt.classList.contains('mqBtn') // pas un bouton créé par j3pPaletteMathquill
  ) {
    return
  }
  if (event.shiftKey && event.key === 'Shift') return
  const { inputMq } = currentVirtualKeyboard
  // if (event.code.indexOf('Shift') !== -1) return
  let code = ''
  if (event.key === 'Tab') {
    if (event.shiftKey) code = 'Shift-Tab'
    else code = 'Tab'
    const MQ = MathQuill.getInterface(2)
    const mathField = MQ.MathField(inputMq)
    const cursor = mathField.__controller.cursor
    const leftInit = cursor['-1']
    const rightinit = cursor['1']
    $(inputMq).mathquill('keystroke', code)
    const left = cursor['-1']
    const right = cursor['1']
    if ((leftInit !== left) || (rightinit !== right)) {
      event.preventDefault()
    }
  } else {
    switch (event.code) {
      case 'Backspace':
        doBackSpaceAction(inputMq)
        break
      // pour enter ça déconne, le premier caractère tapé après dépliement du clavier se retrouve doublé avec la touche entrée
      case 'Enter':
        doEnterAction(inputMq)
        break
      case 'ArrowLeft':
        $(inputMq).mathquill('keystroke', 'Left')
        break
      case 'ArrowRight':
        $(inputMq).mathquill('keystroke', 'Right')
        break
      case 'ArrowDown':
        $(inputMq).mathquill('keystroke', 'Down')
        break
      case 'ArrowUp':
        $(inputMq).mathquill('keystroke', 'Up')
        break
      case 'Delete':
        $(inputMq).mathquill('keystroke', 'Del')
        break
      default:
        // si ça correspond à un truc d’un seul caractère, qui est autorisé sur ce clavier virtuel, on l’ajoute
        if (event.key && event.key.length === 1 && currentVirtualKeyboard.restriction.test(event.key)) {
          doCharAction(inputMq, event.key)
          event.preventDefault()
          // event.stopPropagation() // Nécessaire pour FireFox
        }
      // et sinon on fait rien
    }
  }
}

const pasteListener = (event) => {
  if (!currentVirtualKeyboard) return
  const activeElt = document.activeElement
  // Dans le cas où on un un éditeur Mathquill avec clavier virtuel qui simule le focus, activeElt peut être le body
  // et sinon si l’élement qui a le fovus n’est pas un éditeur MathQuill il ne faut rien faire
  if (activeElt && activeElt !== document.body && (activeElt.classList.length === 0 || !activeElt.classList.contains('mq-editable'))) return
  const clip = event.clipboardData || window.clipboardData
  if (clip) {
    const { inputMq } = currentVirtualKeyboard
    const data = clip.getData('text')
    $(inputMq).mathquill('latex', data)
  }
}

const copyListener = (event) => {
  if (!currentVirtualKeyboard) return
  const activeElt = document.activeElement
  // Dans le cas où on un un éditeur Mathquill avec clavier virtuel qui simule le focus, activeElt peut être le body
  // et sinon si l’élement qui a le fovus n’est pas un éditeur MathQuill il ne faut rien faire
  if (activeElt && activeElt !== document.body && (activeElt.classList.length === 0 || !activeElt.classList.contains('mq-editable'))) return
  const cb = event.clipboardData
  if (cb) {
    const { inputMq } = currentVirtualKeyboard
    cb.setData('text/plain', j3pValeurde(inputMq))
    event.preventDefault()
  }
}

/**
 * Fonction chargée de refaire aparaître le curseur clignotant de MathQuill sans redonner le focus
 * au champ MathQuill
 * @param inputMq
 * @param bshow
 * @private
 */
export const showMqCursor = (inputMq, bshow) => {
  const MQ = MathQuill.getInterface(2)
  const mathField = MQ.MathField(inputMq)
  const controller = mathField.__controller
  const cursor = controller.cursor
  if (bshow) cursor.show()
  else cursor.hide()
}

// un listener unique pour écouter keyup sur body, pour répercuter la frappe clavier physique dans l’input concerné
// par un clavier virtuel ouvert, mais qui n’a pas le focus (pour éviter le clavier virtuel de l’OS)
const enableKeyListeners = () => {
  if (hasBodyListener) return
  // on veut récupérer les événement du clavier physique pour répercuter la frappe dans notre input qui n’a plus le focus
  // document.body.addEventListener('keyup', keyupListener)
  document.body.addEventListener('keydown', keydownListener)
  document.body.addEventListener('paste', pasteListener)
  document.body.addEventListener('copy', copyListener)
  hasBodyListener = true
}
// faut le désactiver dès que l’input a vraiment le focus
const disableKeyListeners = () => {
  if (!hasBodyListener) return
  // document.body.removeEventListener('keyup', keyupListener)
  document.body.removeEventListener('keydown', keydownListener)
  document.body.removeEventListener('paste', pasteListener)
  document.body.removeEventListener('copy', copyListener)
  hasBodyListener = false
}

const focusListener = (inputMq, event) => {
  // logEvent(event, 'avec isFocusedByTouch', inputMq.isFocusedByTouch)
  // Si le clavier n’est pas actif on ne fait rien (il peut avoir été désactivé par j3pDesactive)
  if (!inputMq.virtualKeyboard.isActive) return
  /** @type MqVirtualKeyboard */
  const vk = inputMq.virtualKeyboard
  const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0
  // const isTouchDevice = false

  if (isTouchDevice) {
    // si on est dans un contexte "touch" faut virer le focus dès qu’on le reçoit
    // pour que le clavier virtuel de l’OS ne se montre pas (sinon c’est pas la peine ça change rien)
    enableKeyListeners()
    // on l’ouvre même si y’en a pas d’autres ouvert, parce qu’on a eu le focus avec du touch
    if (vk.isOpen) fakeMqFocus(inputMq)
    else vk.show() // il fera le fakeMqFocus
    // il faut virer le focus pour faire disparaître le clavier virtuel de la tablette
  } else {
    disableKeyListeners()
    // on regarde si on doit ouvrir ce clavier virtuel, si c’est déjà le cas y’a rien à faire,
    // sinon désactiver le focus pour que ça fonctionne comme sur tablette
    if (vk.isOpen) {
      fakeMqFocus(inputMq)
    } else {
      // si y’a un autre clavier ouvert on s’affiche à sa place
      const allKeyboards = Array.from(document.querySelectorAll('.virtualKeyboard'))
      // on a eu un cas avec un div qui n'avait pas de prop virtualKeyboard (https://app.bugsnag.com/sesamath/j3p/errors/68343525d383ca45786b2d5f?filters[event.since]=30d&filters[error.status]=open&filters[error.assigned_to][type]=ne&filters[error.assigned_to][value]=anyone)
      if (allKeyboards.some(div => div.virtualKeyboard?.isOpen)) vk.show()
      // et sinon on laisse le focus clavier à cet inputMq sans déplier ce clavier virtuel
    }
  }
}

const onResize = () => {
  if (!currentVirtualKeyboard || !currentVirtualKeyboard.isOpen || !currentVirtualKeyboard.boundingContainer || !currentVirtualKeyboard.element) return
  // on regarde s’il faut décaler notre clavier virtuel à gauche ou à droite (on change rien en hauteur car on veut rester sous l’inputMq)
  // on translate à gauche si on est trop près du bord droit
  const vk = currentVirtualKeyboard
  if (!vk.element._lastTop) return // le clavier a été déplacé manuellement => on bouge rien
  const { x: bX, right: bRight } = vk.boundingContainer.getBoundingClientRect()
  // on translate à gauche si on est trop près du bord droit
  const { x, right } = vk.element.getBoundingClientRect()
  if (right + boundingMargin > bRight) {
    // ça déborde à droite, on décale à gauche autant qu’on peut (sans déborder à gauche)
    // right-bright : pour caler à droite du conteneur
    // x - bX : pour caler à gauche du conteneur
    // => on prend les opposés (faut décaler vers la gauche) et le max (donc valeur absolue minimale pour décaler le moins possible)
    vk.translatePosition(Math.max(bRight - right - boundingMargin, bX - x + boundingMargin / 2))
  } else {
    // si c’est décalé à gauche de l’input, on regarde si on peut le recaler sur sa position par défaut (left: 0)
    // (ça pourrait aussi déborder à gauche, si on avait décalé à gauche précédemment et que l’input est passé à la ligne depuis),
    const left = parseInt(vk.element.style.left, 10)
    if (left < 0) {
      // on décale vers la droite autant que possible pour aligner le coin supérieur gauche à celui du l’inputMq
      // pour se simplifier la vie (et éviter un éventuel bug qui risquerait de passer inaperçu) on remet à 0 et relance le calcul ci-dessus
      vk.element.style.left = 0
      setTimeout(onResize, 0)
    }
  }
}

/**
 * Met le curseur clignotant dans l’inputMq et le liseret bleu, mais sans lui donner le focus
 * (pour ne pas avoir le clavier virtuel iOS/Android)
 * @private
 * @param inputMq
 */
const fakeMqFocus = (inputMq) => {
  const $inputMq = $(inputMq)
  $inputMq.mathquill('blur')

  inputMq.classList.add('fakeMqFocus')
  // et lancer qqchose pour remettre le curseur clignotant dans l’input, avec un setTimeout pour passer après la propagation du blur
  // (sinon on a pas le curseur clignotant)
  setTimeout(() => {
    showMqCursor(inputMq, true)
  })
}

// on pourrait détecter en statique les capacités touch avec ça
// cf https://hacks.mozilla.org/2013/04/detecting-touch-its-the-why-not-the-how/
// const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0
// mais on préfère gérer chaque cas, input qui a reçu le focus avec un touch (on ouvre automatiquement)
// ou avec un clic (on ouvre si y’en avait un autre ouvert)

let hasBodyListener = false
/**
 * @type {MqVirtualKeyboard}
 * @private
 */
let currentVirtualKeyboard

/**
 * @typedef MqVirtualKeyboardOptions
 * @property {string[]} [commandes] Une liste éventuelle de commandes mathquill
 * @property {HTMLElement} [boundingContainer] Un parent dans lequel il faut essayer de rester (que le clavier ne déborde pas)
 */

/**
 * À priori pas besoin d’instancier directement cette classe, l’appel de {@link module:mqFunctions.mqRestriction mqRestriction} devrait suffire
 * Cf également le tuto {@tutorial MqVirtualKeyboard} pour migrer de {@link module:j3pFunctions.j3pRestriction} à {@link module:lib/mathquill/functions.mqRestriction mqRestriction}
 */
class MqVirtualKeyboard {
  /**
   * Instancie un clavier virtuel et ses éléments mais ne l’insère pas dans le dom (insérer sa propriété element où on voudra ensuite)
   * @param {HTMLElement} inputMq
   * @param {RegExp} restriction
   * @param {MqVirtualKeyboardOptions} [options]
   */
  constructor (inputMq, restriction, options = {}) {
    if (!restriction) restriction = /./ // on autorise tout, ce sera limité plus loin par ce qu’on gère dans un clavier virtuel
    if (!(restriction instanceof RegExp)) throw Error('restriction invalide')
    // si on nous passe une restriction qui vérifie depuis le début de la chaîne, on vire cette restriction
    // (elle s’appliquera quand même dans restrict mais si on la laissait ici on n’aurait que le 1er caractère autorisé comme bouton)
    const regSrc = restriction.source
    if (regSrc.startsWith('^')) {
      restriction = new RegExp(regSrc.substring(1), restriction.flags)
    } else {
      // on la clone, pour que notre usage ne modifie pas son pointeur interne lastIndex, au cas où qqun s’en servirait
      restriction = new RegExp(regSrc, restriction.flags)
    }
    /**
     * Sera mis à false quand on veut le désactiver par exemple dans j3PDesactive (le boutont pour déplier devient alors inactif)
     * @type {boolean}
     */
    this.isActive = true
    /**
     * L’input mathquill
     * @type {HTMLElement}
     */
    this.inputMq = inputMq
    /**
     * La liste des touches du clavier virtuel
     * @type {RegExp}
     */
    this.restriction = restriction
    if (options.boundingContainer) {
      /**
       * Le parent dans lequel le clavier se repositionne pour rester visible
       * @type {HTMLElement}
       */
      this.boundingContainer = options.boundingContainer
    } else {
      // on cherche le premier parent divZone
      const zone = getZoneParente(inputMq, true)
      if (zone) this.boundingContainer = zone
    }
    // on s’attache à l’inputMq, pour que d’autres puissent nous retrouver via le dom
    // (c’est utilisé par ButtonEnter pour masquer le clavier virtuel par ex)
    inputMq.virtualKeyboard = this
    // on ajoute une méthode fakeFocus à cet inputMq, que j3pFocus utilisera si ça existe
    inputMq.fakeFocus = fakeMqFocus.bind(null, inputMq)

    const triggerBtn = new Button({
      className: 'triggerButton',
      value: '⇓',
      onClick: () => this.toggle()
    })
    /**
     * le bouton déclencheur
     * @type {HTMLElement}
     */
    this.triggerElement = triggerBtn.element

    // La chaîne contenant tous les caractères éventuels (qui pourraient être filtrés par la regExp restriction)
    // @todo if (restriction.flag.includes('i')) => retirer les majuscules de cette liste et ajouter un bouton Maj pour les gérer
    // on crée des listes pour passer à la ligne entre chaque liste
    const allChars = ['0123456789,.²', '+-*/=^', ';()[]<>|', 'aàâbcçdeéèêëfghiïjklmnoôpqrstuùûvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ']
    const allowedChars = allChars.map(list => Array.from(list).filter(char => restriction.test(char))).filter(l => l.length)

    const div = document.createElement('div')
    div.classList.add('virtualKeyboard')
    div.style.display = 'none'
    /**
     * Le div contenant le clavier virtuel
     * On lui ajoutera une propriété virtualKeyboard contenant cette instance de MqVirtualKeyboard
     * @type {HTMLDivElement}
     */
    this.element = div
    /**
     * @type {boolean}
     */
    this.isOpen = false
    // on s’ajoute aussi à cet elt (c’est lui qu’on récupère avec du querySelectorAll('.virtualKeyboard')
    this.element.virtualKeyboard = this

    // //////////////////////
    // comportements
    // //////////////////////

    // ordre des event
    // - focusin sur inputMq
    // - touchstart sur inputMq
    // - touchend sur inputMq
    // - focus sur inputMq.querySelector('textarea')
    // - click sur inputMq
    // => on lance onFocus sur les deux derniers

    // La ligne suivante doit être présente pour que cela fonctionne beien avec le clavier
    inputMq.querySelector('textarea').addEventListener('focus', focusListener.bind(null, inputMq))

    // Le addEventListener suivant améliore le problème du curseur fantôme non clignotant sur tablette.
    // Finalement plus utilisé depuis qu ela chasse au curseur parasite a été délégué à mathquill
    // dans la fonction blink

    // faut remettre le listener si on perd le focus en gardant le clavier ouvert
    inputMq.addEventListener('focusout', (event) => {
      if (this.isOpen) enableKeyListeners()
    })

    if (this.boundingContainer) {
      // ça marche aussi sur le changement d’orientation sur iOS et android
      window.addEventListener('resize', onResize)
      // et ça tombe bien car ce qui suit n’est jamais appelé au changement d’orientation
      // const eventTarget = (typeof screen === 'object' && typeof screen.addEventListener === 'function') ? screen : window
      // eventTarget.addEventListener('deviceorientation', onResize)
    }

    /**
     * Retourne la position relative du touch ou du clic par rapport à sa cible (position dans event.target)
     * @param {TouchEvent|MouseEvent} event
     * @return {number[]} [x, y]
     * @private
     */
    const getPosition = (event) => {
      const rect = div.getBoundingClientRect()
      const ref = (event.targetTouches && event.targetTouches[0]) || event
      return [ref.clientX - rect.x, ref.clientY - rect.y]
    }

    // div draggable (pour utiliser le drag&drop natif) marche pas au touch, et ça marche plus non plus quand on se retrouve dans un mq-math-mode :-/
    // on gère tout ensemble
    const onDragStart = (event) => {
      // sur iPad faut un preventDefault sinon toute la page bouge, sauf sur les <button> sinon ça déclenche plus le click
      // => on ignore le drag sur un bouton et on fera un preventDefault dans les autres cas
      if (event.target.tagName.toLowerCase() !== 'div') return // on fait rien sur les <button> ou les <span> qu’ils contiennent
      div.isDragging = true
      const [x, y] = getPosition(event)
      div._dragStartX = x
      div._dragStartY = y
      div.addEventListener('touchmove', onDragMove)
      // pour le mousemove faut le mettre sur body, sinon la souris sort trop facilement du div dès qu’on bouge un peu trop vite
      document.body.addEventListener('mousemove', onDragMove)
      if (event.cancelable) event.preventDefault()
    }
    const onDragMove = (event) => {
      if (div.isDragging) {
        const [x, y] = getPosition(event)
        const decalX = x - div._dragStartX
        const decalY = y - div._dragStartY
        this.translatePosition(decalX, decalY)
        div._lastTop = null
      }
    }
    const onDragEnd = () => {
      if (!div.isDragging) return
      div.isDragging = false
      div.removeEventListener('touchmove', onDragMove)
      document.body.removeEventListener('mousemove', onDragMove)
    }
    // pour drag&drop au touch ou à la souris
    div.addEventListener('mousedown', onDragStart)
    // faut préciser passive: false pour indiquer au navigateur qu’on va utilise preventDefault (si on le dit pas ça marche quand même mais chrome met un warning en console)
    div.addEventListener('touchstart', onDragStart, { passive: false })
    document.body.addEventListener('mouseup', onDragEnd)
    div.addEventListener('touchend', onDragEnd)

    // faut repositionner le clavier à chaque fois que l’input change (s’il devient plus haut avec des
    // fractions faut se décaler pour pas le recouvrir, idem si ça diminue)
    // init avec la taille actuelle de l’input
    const { height } = inputMq.getBoundingClientRect()
    div._lastTop = height
    div.style.top = (height + decalageSousInputMq) + 'px'
    // keyup fonctionne pour les saisies du clavier physique mais aussi pour les clics sur les boutons du clavier virtule,
    // on l’écoute pour décaler notre div si besoin (si l’inputMq a changé de hauteur après un clic sur fraction par ex)
    inputMq.addEventListener('keyup', () => {
      if (!div._lastTop) return // le clavier a été déplacé manuellement => on bouge rien
      const { height } = inputMq.getBoundingClientRect()
      if (div._lastTop === height) return
      // ça a changé
      div._lastTop = height
      div.style.top = (height + decalageSousInputMq) + 'px'
    })

    // ///////////////////////////////
    // ajout du contenu du clavier
    // ///////////////////////////////

    // A gauche un bloc contenant les boutons pour les caractères
    const blockChars = j3pAddElt(div, 'div')
    allowedChars.forEach((chars, i) => {
      if (i) j3pAddElt(blockChars, 'br')
      chars.forEach(char => ButtonMq.addInto(blockChars, inputMq, { value: char }))
    })
    /**
     * Le bloc des boutons de commande
     * @type {HTMLDivElement}
     */
    this.commandsContainer = j3pAddElt(blockChars, 'div', '', { className: 'mqCommandes' })
    if (Array.isArray(options.commandes)) {
      options.commandes.forEach(commande => this.addCommand(commande))
    }

    // un block à droite pour les boutons "meta"
    const blockMeta = j3pAddElt(div, 'div')
    // qui contient deux block, un pour les flèches
    const blockArrows = j3pAddElt(blockMeta, 'div')
    const arrows = ['Left', 'Right', 'Up', 'Down']
    arrows.forEach(arrow => ButtonArrow.addInto(blockArrows, inputMq, arrow))
    // et un autre pour backspace et enter
    const blockDelEnter = j3pAddElt(blockMeta, 'div')
    ButtonBackSpace.addInto(blockDelEnter, inputMq)
    ButtonEnter.addInto(blockDelEnter, inputMq)
  } // constructor

  /**
   * Crée un MqVirtualKeyboard attaché à un inputMq (découple l’instanciation de l’objet et la manipulation du dom)
   * @param {HTMLElement|string} inputMq
   * @param {RegExp|null} restriction
   * @param {MqVirtualKeyboardOptions} [options]
   */
  static create (inputMq, restriction, options) {
    if (!j3pIsHtmlElement(inputMq) || !inputMq.classList.contains('mq-editable-field')) throw Error('input mathquill invalide')
    if (inputMq.virtualKeyboard) {
      // cet input a déjà un clavier, on pourrait virer ses éléments, mais on peut pas virer les listeners sur inputMq
      // par ailleurs, on voit pas très bien dans quel cas on pourrait vouloir créer un 2e clavier virtuel différent sur le même inputMq
      throw Error('Cet input mathquill a déjà un clavier virtuel qui lui est attaché')
    }
    const parent = inputMq.parentNode
    // On crée un div qui va englober le span contenant l’éditeur MathQuill
    const div = document.createElement('div')
    // On impose la position relative à ce div pour que le clavier virtuel puisse se positionner en absolute par rapport à lui
    div.classList.add('kbdContainer')
    // on le positionne juste avant l’input
    parent.insertBefore(div, inputMq)
    // et on déplace l’inputMq à l’intérieur de ce div
    div.appendChild(inputMq)
    // on instancie un clavier virtuel
    const virtualKeyboard = new MqVirtualKeyboard(inputMq, restriction, options)
    // on ajoute un bouton pour faire apparaître le clavier
    div.appendChild(virtualKeyboard.triggerElement)
    // puis le clavier
    div.appendChild(virtualKeyboard.element)
  }

  /**
   * Ajoute un bouton de commande mathquill (cf commands dans src/lib/mathquill/functions.js)
   * @param {string} command
   */
  addCommand (command) {
    ButtonMqCommand.addInto(this.commandsContainer, this.inputMq, { command })
  }

  /**
   * Décale le div du clavier virtuel
   * @param decalX Le décalage en abscisse
   * @param decalY Le décalage en ordonnée
   */
  translatePosition (decalX, decalY) {
    const div = this.element
    if (!div) return // ça peut arriver si le clavier vient d’etre détruit
    const { left, top } = getComputedStyle(div)
    div.style.left = (parseInt(left, 10) + decalX) + 'px'
    div.style.top = (parseInt(top, 10) + decalY) + 'px'
  }

  show () {
    for (const vkElement of document.querySelectorAll('.virtualKeyboard')) {
      if (!vkElement.virtualKeyboard) {
        console.error(Error('élément .virtualKeyboard sans instance de virtualKeyboard'), vkElement)
        continue
      }
      if (vkElement.virtualKeyboard.isOpen) {
        vkElement.virtualKeyboard.hide()
      }
    }
    this.element.style.display = 'inline-block'
    this.triggerElement.innerText = '⇑'
    fakeMqFocus(this.inputMq)
    this.isOpen = true
    currentVirtualKeyboard = this
    enableKeyListeners()
    if (this.boundingContainer) {
      // il faut un setTimeout pour passer après un éventuel retour à la ligne de l’inputMq
      // (s’il est à ras du bord droit, ouvrir le clavier peut renvoyer l’input au début de la ligne suivante
      // et dans ce cas getBoundingClientRect retourne les anciennes positions)
      setTimeout(onResize, 10)
    }
  }

  hide () {
    if (!this.isOpen) return
    this.element.style.display = 'none'
    this.triggerElement.innerText = '⇓'
    // avec du `this.inputMq.blur()` ça déconne (casse le show suivant)
    $(this.inputMq).mathquill('blur')
    this.isOpen = false
    this.inputMq.classList.remove('fakeMqFocus')
    currentVirtualKeyboard = null
  }

  toggle () {
    if (this.isActive) {
      if (this.element.style.display === 'none') this.show()
      else this.hide()
    }
  }

  /**
   * Fonction appelée pour rendre le clavier virtuel actif ou inactif suivant la valeur de bActive
   * @param {boolean} bActive
   */
  setActive (bActive) {
    this.isActive = bActive
    const sel = this.inputMq.parentNode.querySelector('.triggerButton')
    if (bActive) {
      sel.style.display = 'inline-block'
    } else { // Si on désactive le clavier virtuel et qu’il est visible on le masque
      if (this.isOpen) this.hide()
      sel.style.display = 'none'
    }
  }
}

export default MqVirtualKeyboard