lib/outils/mathlive/display.js

import { j3pAddElt, j3pElement, j3pEnsureHtmlElement, j3pGetCssProp, j3pGetNewId } from 'src/legacy/core/functions'
import { MathfieldElement, renderMathInElement } from 'mathlive'
import { convertRestriction } from 'src/lib/utils/regexp'
import VirtualKeyboard from 'src/lib/widgets/mlVirtualKeyboard/VirtualKeyboard'
import { inputAutoSizeListener } from 'src/lib/mathquill/functions'
import { cleanLatexForMl, correctArrayLignes, replaceOldMQCodes, traiteEspacementLignesMatrices, transposeMatrices } from 'src/lib/outils/mathlive/utils'
import { getIndexFermant } from 'src/lib/utils/string'

// Fonction je l’espère provisoire remplaçant les codes \le et \ge mis automatiquement par Mathlive
// à la place des ≤ et ≥ dans les \text{} (de dernier niveau)
function correctInegCodes (ch) {
  const st = ch
  let indstart = 0
  while ((indstart = ch.indexOf('\\text{', indstart)) !== -1) {
    const indfin = getIndexFermant(ch, indstart + 5)
    if (indfin === -1) return st
    const content = ch.substring(indstart + 6, indfin)
    const newcontent = content.replace(/≤/g, '$\\le$').replace(/≥/g, '$\\ge$')
    ch = ch.substring(0, indstart + 6) + newcontent + ch.substring(indfin)
    indstart = indfin + newcontent.length - content.length + 1
  }
  return ch
}

/**
 * Fonction affichant la chaîne ch en la transformant par un contenu compatible mathlive
 * @param {HTMLElement} container le conteneur
 * @param {string} idspan l’id à affecter au span qui va être créé et contiendra l’affichage MathQuill
 * @param {string} ch la chaîne à traiter
 * @param {Object} [options]
 * @param {string} [options.charset]
 * @param {boolean} [options.charsetText]
 * @param {string[]} [options.listeBoutons]
 * @param {boolean} [options.transposeMat] mettre à true si on peut que les matrices soient transposées
 * avant le remplacement des \editable{} pas des placeholders pour compatibilité avec les anciennes sections
 * puis retransposées après pour être compatible avec l’ancienne version MathQuill (inversions des lignes et des colonnes)
 * @param {boolean} [options.replacefracdfrac] Si présent et true, on remplace les codes \frac par des codes \dfrac
 * @param {boolean} [options.cleanHtml] si present et false, on ne nettoie pas le code HTML
 */
export function afficheMathliveDans (container, idspan, ch, options = {}) {
  if (typeof ch === 'number') {
    if (!Number.isFinite(ch)) {
      throw Error(`Il faut passer une chaîne (number ${ch})`)
    }
    // sinon on râle mais on tolère
    console.warn(Error(`afficheMathliveDans veut une string (number ${ch})`))
    ch = String(ch)
  }
  if (typeof ch !== 'string') {
    throw Error(`Chaîne invalide (${typeof ch} ${ch})`)
  }
  // on dit rien sur les chaînes vides
  if (!ch) return

  MathfieldElement.plonkSound = null // On désactive tous les sons en global
  if (idspan && typeof idspan === 'string') {
    // on s’assure qu’il n’y a pas déjà un élément avec cet id (et si jamais y’en avait un ça va râler en console et lui ajouter un suffixe numérique)
    idspan = j3pGetNewId(idspan, true)
  } else {
    // sinon on en génère un car il sert ensuite de préfixe un peu partout
    idspan = j3pGetNewId('affiche')
  }

  const charset = options.charset || ''
  const charsetText = options.charsetText || ''
  const listeBoutons = options.listeBoutons || []
  container = j3pEnsureHtmlElement(container)
  const realcont = j3pEnsureHtmlElement(container)

  // On corrige une icncompatibilité d’affichage entre Mathjax et Mathlive pour les array d’une seule ligne
  ch = correctArrayLignes(ch)
  // Important ! : Ligne suivante : vec MathLive on se retrouve avec de \right) affichés quand des \right[ contienney
  // des \left( avec du \displaystyle dedans
  // Semble résolu avec la version 0.98.5 de  Mathlive
  // ch = ch.replace(/\\displaystyle/g, ' ')
  const span = j3pAddElt(realcont, 'span', '', { id: idspan })
  // On remplace les éléments passés en paramètre via & suivi d’une lettre
  if (options) {
    for (const [key, value] of Object.entries(options)) {
      if (key.length === 1) ch = ch.replace('&' + key, value)
    }
  }
  // On remplace les éventuels paramètres
  /*
    Array.from('abcdefghijklmnopqrstuvwxyz').forEach((value) => {
      // Attention : Mathlive n’aime pas les \n qui pourraient se trouver dans les paramètres Latex
      if (options[value]) ch = ch.replace(new RegExp('£' + value, 'g'), options[value].replaceAll('\n', ' '))
    })
     */
  for (let [prop, value] of Object.entries(options)) {
    // avec ou sans {}, indépendamment de la longueur de p (une lettre ou plusieurs)
    if (typeof value === 'number') value = String(value)
    if (typeof value === 'string') {
      value = value.replaceAll('\n', ' ')
      ch = ch
        .replace(new RegExp('£{' + prop + '}', 'g'), value)
        .replace(new RegExp('£' + prop, 'g'), value)
    }
  }
  // On nettoie ch des balises interdites
  if (options.cleanHtml !== false) ch = resumeHtml(escHtml(ch))
  ch = replaceOldMQCodes(ch)
  // Ligne suivante à supprimer une fois Mathlive corrigé
  ch = correctInegCodes(ch)
  // On crée les inputs
  if (ch.includes('@1@')) {
    let indText = 1
    while (ch.includes('@' + indText + '@')) {
      const idtext = '@' + indText + '@'
      const inddeb = ch.indexOf(idtext)
      const indfin = inddeb + idtext.length
      const inputValue = options['input' + indText]
      const value = inputValue.texte === '' ? '' : 'value="' + inputValue.texte + '"'
      const color = inputValue.couleur || options?.style?.color
      const fontSize = (inputValue?.taillepolice && (inputValue.taillepolice + 'px')) || j3pGetCssProp(container, 'fontSize') || '20px'
      const size = inputValue.taille || 20
      const largDynDefaut = 20
      const largInputDefaut = 100
      const width = 'width:' + (inputValue.dynamique ? largDynDefaut : largInputDefaut) + 'px'
      let maxchars
      if (typeof inputValue.maxchars === 'number' || (typeof inputValue.maxchars === 'string' && /^\d+$/.test(inputValue.maxchars))) {
        maxchars = inputValue.maxchars
      } else {
        maxchars = ''
      }
      let maxlength
      if (typeof inputValue.maxlength === 'number' || (typeof inputValue.maxlength === 'string' && /^\d+$/.test(inputValue.maxlength))) {
        maxlength = inputValue.maxlength
      } else {
        maxlength = maxchars // éventuellement vide
      }
      let style = `size:${size};${width};font-size:${fontSize};`
      if (color) style += `color:${color};`
      const textId = j3pGetNewId(idspan + 'input' + indText, true)
      // Si on a un charsetText, on donne à l’input la classe MLInput ce qui permettra du lui affecter un clavier virtuel
      const cl = charsetText ? 'class = "inputWithKeyboard"' : ''
      const html = `<span><input type="text" inputmode="none" maxchars="${maxchars}" autocomplete="off" size="${size}" id="${textId}" maxlength="${maxlength}" ${cl}  ${value} style="${style}"></span> `
      ch = ch.substring(0, inddeb) + html + ch.substring(indfin)
      indText++
    }
  }
  // On crée les listes déroulantes
  if (ch.includes('#1#')) {
    let indListe = 1
    while (ch.includes('#' + indListe + '#')) {
      const idliste = '#' + indListe + '#'
      const inddeb = ch.indexOf(idliste)
      const indfin = inddeb + idliste.length
      const opt = options['liste' + indListe]
      const fontSize = (opt?.taillepolice && (opt.taillepolice + 'px')) || j3pGetCssProp(container, 'fontSize') || '18px'
      let style = `font-size:${fontSize};`
      const color = opt?.couleur || j3pGetCssProp(container, 'color')
      if (color) style += `color:${color};`
      const spanId = j3pGetNewId(idspan + 'spanListe' + indListe, true)
      const selectId = j3pGetNewId(idspan + 'liste' + indListe, true)
      let html = `<span id="${spanId}"><form style="display:inline"><select id="${selectId}" size="1" style="${style}">`
      if (opt?.texte) {
        for (const txt of opt.texte) {
          html += '<option>' + txt + '</option>'
        }
      } else {
        console.error(Error('Aucune option texte à mettre dans le select'))
        html += '<option>Désolé, aucun choix n’a été paramétré</option>'
      }
      html += '</select></form></span>'
      ch = ch.substring(0, inddeb) + html + ch.substring(indfin)
      indListe++
    }
    // S’il reste encore des list suivi d’un chiffre c’est une  erreur (des indices de list non consécutifs)
    if (/#\d+/g.test(ch)) {
      console.error(Error('indices de liste non consécutifs dans afficheMathlive'))
    }
  }
  // On crée les éditeurs de formule simples
  let i = 1
  while (ch.includes('&' + i + '&')) {
    ch = ch.replace('&' + i + '&', '<math-field id=' + idspan + 'inputmq' + i + '></math-field>')
    i++
  }
  // On crée les éditeurs de formule pour les editable. Un seul éditeur mathfield pour le contenu des $
  // Les éditeurs mathlive contenant des placeHolder ont pour classe PhBlock et une id
  // qui est expressionPhBlock suivi de l’indice de cet éditeur dans expression
  // Les placeHolder qu’il contient on pour id l’id de ce bloc suivi de ph suivi de leur
  // indice global dans l’expression
  // A cause du traitement différent des matrices dans Mathquill et MathLive, on transpose
  // d’abord les matrices de ch puis on affecte les numéros des \editable qui sont ensuite remplacés
  // par des placeholder puis on transpose à nouveau toutes les matrices du résultat
  const hasEditable = options.transposeMat && ch.includes('\\begin{matrix}') && ch.includes('\\editable{}')
  if (hasEditable) ch = transposeMatrices(ch)
  let nbEditable = 1 // Nombre global des editable rencontrés
  // Chacun des blocs contenant des editable aura pour id PhBlock (Ph pour placeholder) suivi de son numéro
  let nbPhBlocks = 0
  while (ch.includes('\\editable{}')) {
    nbPhBlocks++
    // On recherche le premier caractère $ qui précède
    const indedit = ch.indexOf('\\editable{}')
    let j
    for (j = indedit - 1; j >= 0; j--) {
      if (ch.charAt(j) === '$') break
    }
    let k = ch.indexOf('$', indedit)
    if (k === -1) k = ch.length
    let str = ch.substring(j + 1, k)
    const id = idspan + 'PhBlock' + nbPhBlocks
    while (str.includes('\\editable{}')) {
      str = str.replace('\\editable{}', '\\placeholder[' + id + 'ph' + nbEditable + ']{#0}')
      nbEditable++
    }
    ch = ch.substring(0, j) + '<math-field readonly class= "PhBlock" ' + 'id=' + id + '>' + str + '</math-field>' + ch.substring(k + 1)
  }
  // On retranspose pour retrouver les matrices de départ si on a déjà transposé
  if (hasEditable) ch = transposeMatrices(ch)

  // le replacer du contenu entre $
  const replacer = (match, p1) => {
    const content = p1
      // ajoute une espace après chaque < immédiatement suivi d'une lettre (bug mathlive)
      .replace(/<([a-zA-Z])/g, '< $1')
      // Il ne faut pas de \displaystyle à l’intérieur des matrices Mathlive n’aime pas
      .replaceAll('\\displaystyle', ' ')
    const newcontent = traiteEspacementLignesMatrices(content)
    // Et on met les formules en inline
    return '$' + '\\displaystyle ' + newcontent + '$'
  }
  if (options.replacefracdfrac) ch = ch.replace(/\\frac/g, '\\dfrac')
  ch = ch.replace(/\$([^$]+)\$/g, replacer)
  const st = ch.split('<br>')
  for (let i = 0; i < st.length; i++) {
    // La première ligne est inline et les suivantes dans des <p>
    const par = j3pAddElt(span, i === 0 ? 'span' : 'p', st[i])
    par.style.paddingTop = '4px' // Pour que les lignes ne see chevauchent pas
    par.style.paddingBottom = '4px' // Pour que les lignes ne see chevauchent pas
    if (options?.style) {
      for (const [key, value] of Object.entries(options.style)) {
        par.style[key] = value
      }
    }
    // renderMathInElement(par, { renderAccessibleContent: '' })
    renderMathInElement(par, {
      renderAccessibleContent: '', // Pas de message pour non-voyants etc ...
      TeX: {
        delimiters: {
          // Allow math formulas surrounded by $...$ to be rendered as inline (textstyle) content.
          inline: [
            ['$', '$']
          ],
          display: [] // Pas de délimiteurs pour passer en mode texte
        }
      }
    })
  }
  let restriction = charset
  if (typeof restriction === 'string') restriction = convertRestriction(restriction)
  let restrictionInput = charsetText
  if (typeof restrictionInput === 'string') restrictionInput = convertRestriction(restrictionInput)
  container.querySelectorAll('math-field').forEach((mf) => {
    mf.menuItems = []
    mf.inlineShortcuts = { // Pas de shortcuts à part ces deux là
      '*': restriction.test('*') ? '\\times' : '', // On affecte à la touche * le signe de multiplication (\cdot par défaut)
      '.': restriction.test('.') ? ',' : '',
      '[': '[', // Ne pas tester car si '', insère des double crochets automatiquement
      '<': '<',
      $: '' // Pas de caractère $
    }
    // eslint-disable-next-line no-new
    new VirtualKeyboard(mf, restriction, {
      commandes: listeBoutons
    })
    // On déplace toute cette logique dans le constructeur de VirtualKeyboard
    // addEventListenersToEditor(mf)
    // Je retire le bouton de menu à la main plutôt qu’avec le css pas très fiable
    // const menuToggleButton = mf.shadowRoot.querySelector('.ML__menu-toggle')
    // menuToggleButton.parentNode.removeChild(menuToggleButton)
    // const toggleButton = mf.shadowRoot.querySelector('.ML__virtual-keyboard-toggle')
    // ON va remplacer le "bouton' mathlive par notre propre bouton
    // const divtb = createToggleButton(mf)
    // toggleButton.parentNode.replaceChild(divtb, toggleButton)
    // On laisse de la place entre le champ d’édition et le bouton permettant de basculer le clavier
    // mf.shadowRoot.querySelector('.ML__content').style.marginRight = '8px'
  })
  container.querySelectorAll('input[type=text]').forEach((input) => {
    if (input.classList.contains('inputWithKeyboard')) { // Si l’input texte a un clavier virtuel associé
      // eslint-disable-next-line no-new
      new VirtualKeyboard(input, restrictionInput, {
        acceptSpecialChars: true // Si le caractère espace est dans le charSet pour un input texte on le met dans le clavier
      })
      // On déplace toute cette logique dans le constructeur de VirtualKeyboard
      // addEventListenersToEditor(input)
      // const divtb = createToggleButton(input)
      // input.parentNode.appendChild(divtb)
    }
    // Il faut rendre tous les input de texte autosize
    input.addEventListener('input', inputAutoSizeListener, false)
  })
} // afficheMathliveDans

// faut remplacer temporairement les &nbsp; et autres &lt; pour que le & ne soit pas interprété comme mqedit
// mais aussi les tags html, pour les contrôler et autoriser le < dans la string
// Bonne idée ce choix de & comme caractère spécial :-D
function escHtml (ch) {
  if (!ch) return ''
  return ch
  // lui n’a pas besoin d’un tag car le \n est géré
    .replace(/<br *\/?>/gi, '\n')
  // il y a des tonnes de sections qui envoient du html, qu’il faut conserver… on liste qq tags autorisés
  // tag auto fermant
    .replace(/<hr(\s+\/)?>/gi, '¿¿¿¿hr¡¡¡¡')
  // tags sans attributes (*? pour être "non gourmand" => s’arrêter au premier </tagFermant> trouvé et pas au dernier)
    .replace(/<(b|em|i|strong|sub|sup|u)>(.*?)<\/\1>/gsi, (match, tag, content) => `¿¿¿¿${tag}¡¡¡¡${content}¿¿¿¿/${tag}¡¡¡¡`)
  // on autorise img|p|span avec attributs, mais pas n’importe lesquels (surtout pas onload par ex, ça permettrait d’exécuter du js arbitraire)
    .replace(/<img( [^>]+)\/?>/gs, (match, strAttrs) => {
      if (strAttrs) return `¿¿¿¿img ${cleanAttributes(strAttrs)}/¡¡¡¡`
      console.error(Error('tag <img /> sans attributs'))
      return ''
    })
    .replace(/<(p|span)( [^>]+)?>(.*?)<\/\1>/gs, (match, tag, strAttrs, content) => {
      if (strAttrs) return `¿¿¿¿${tag} ${cleanAttributes(strAttrs)}¡¡¡¡${content}¿¿¿¿/${tag}¡¡¡¡`
      return `¿¿¿¿${tag}¡¡¡¡${content}¿¿¿¿/${tag}¡¡¡¡`
    })
    .replace(/&(nbsp|lt|gt|dollar);/g, (match, chunk) => '¿¿¿' + chunk + '¡¡¡')
    .replace(/&#([0-9]+);/g, (match, chunk) => '¿¿¡' + chunk + '¡¡¡')
}

function resumeHtml (ch) {
  if (!ch) return ''
  return ch
    .replace(/¿¿¿¿/g, '<')
    .replace(/¡¡¡¡/g, '>')
    .replace(/¿¿¿/g, '&')
    .replace(/¿¿¡/g, '&#')
    .replace(/¡¡¡/g, ';')
    .replace(/\n/g, '<br>') // on remplace les éventuels \n mis dans contenu au départ
}
/**
 * Contrôle les attributs qu’on autorise (class|width|height) et supprime les autres (faut pas de style ni de onload="js arbitraire")
 * @private
 * @param {string} strAttrs liste d’attributs x="y"
 * @returns {string} les attributs autorisés
 */
function cleanAttributes (strAttrs) {
  if (!strAttrs) return ''
  let cleanAttrs = ''
  let reste = strAttrs // faut pas modifier la string dans le while
  const regex = /(alt|class|height|src|tabindex|width)=(['"])(.*?)\2/g
  let chunks
  while ((chunks = regex.exec(strAttrs)) != null) {
    const [match] = chunks
    cleanAttrs += ` ${match}`
    reste = reste.replace(match, '')
  }
  reste = reste.replace(/\s/g, '')
  if (reste) {
    if (!/^\s*\/\s*$/.test(reste)) console.warn(Error(`attributs ignorés : ${reste}`))
  }
  return cleanAttrs
}

/**
 * Retourne la valeur d’un input mathlive (en latex)
 * @param {HTMLElement|string} elt
 * @param {Object} [options]
 * @param {string} [options.placeholderId] si fourni on fera du getPromptValue de ce placeHolder (plutôt que du getValue sur elt)
 * @param {string} [options.raw = false] passer true pour retourner la valeur brute du champ (sans la passer à cleanLatexForMl avant de la retourner)
 * @return {string}
 */
export function getMathliveValue (elt, { placeholderId, raw } = {}) {
  if (typeof elt === 'string') elt = j3pElement(elt)
  if (!elt || elt.tagName.toUpperCase() !== 'MATH-FIELD') {
    console.error(Error('élément mathlive invalide'), elt)
    // important de retourner une string pour éviter que la suite plante
    return ''
  }
  const value = placeholderId
    ? elt.getPromptValue(placeholderId, 'latex-unstyled')
    : elt.getValue('latex-unstyled')
  if (raw) return value ?? ''
  return cleanLatexForMl(value)
}