dialogs/addLatexHighlight.js

// Empêche Prism de lancer son highlightAll automatique : à placer avant l'import de prismjs
import Prism from 'prismjs'
import 'prismjs/components/prism-latex'

const props = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'tabSize',
  'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
  'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth']

// Paires de délimiteurs dont on affiche l'appariement.
// Pour ajouter les crochets ou les parenthèses : ['[', ']'], ['(', ')']
const PAIRS = [
  { open: '{', close: '}', error: true },
  { open: '(', close: ')', error: false }
]

/**
 * Renvoie les positions des délimiteurs non échappés et l'appariement open/close
 * @param {string} txt
 * @param {string} open
 * @param {string} close
 * @returns {{valid: Set<number>, matches: Map<number, number>}}
 */
function computeMatches (txt, open, close) {
  const valid = new Set()
  const matches = new Map()
  const stack = []
  for (let i = 0; i < txt.length; i++) {
    const c = txt.charAt(i)
    if (c !== open && c !== close) continue
    // Un délimiteur précédé d'un nombre impair de \ est échappé (\{ ou \}) : on l'ignore
    let nbBackslash = 0
    for (let j = i - 1; j >= 0 && txt.charAt(j) === '\\'; j--) nbBackslash++
    if (nbBackslash % 2 === 1) continue
    valid.add(i)
    if (c === open) stack.push(i)
    else if (stack.length !== 0) {
      const o = stack.pop()
      matches.set(o, i)
      matches.set(i, o)
    }
  }
  return { valid, matches }
}

/**
 * Entoure d'un span le caractère situé à la position index du texte (textContent) de root
 */
function wrapChar (root, index, cls) {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
  let off = 0
  let node
  while ((node = walker.nextNode())) {
    const len = node.nodeValue.length
    if (index < off + len) {
      const rest = node.splitText(index - off) // rest commence au caractère cherché
      rest.splitText(1) // rest ne contient plus que ce caractère
      const span = document.createElement('span')
      span.className = cls
      rest.parentNode.replaceChild(span, rest)
      span.appendChild(rest)
      return
    }
    off += len
  }
}

/**
 * Ajoute une coloration syntaxique LaTeX et la mise en évidence des accolades associées sous un textarea existant.
 * Il faut ajouter au DOM hl.container (qui contient le textarea) à la place du textarea.
 * @param {HTMLTextAreaElement} ta
 * @returns {{container: HTMLElement, refresh: function, syncStyle: function}}
 */
export function addLatexHighlight (ta) {
  const container = document.createElement('div')
  container.className = 'mtg-latex-hl'
  container.style.cssText = 'position:relative;display:inline-block;background:#fff;'
  const pre = document.createElement('pre')
  pre.setAttribute('aria-hidden', 'true')
  pre.style.cssText = 'position:absolute;top:0;left:0;margin:0;box-sizing:border-box;overflow:hidden;' + 'white-space:pre;pointer-events:none;border-style:solid;border-color:transparent;'
  const code = document.createElement('code')
  code.style.cssText = 'display:inline-block;padding:0 20px 20px 0;font:inherit;'
  pre.appendChild(code)
  container.appendChild(pre)
  container.appendChild(ta)

  ta.style.background = 'transparent'
  ta.style.color = 'transparent'
  ta.style.caretColor = '#000'
  ta.style.position = 'relative'
  ta.style.zIndex = '1'

  let html = '' // Dernier code HTML coloré par Prism (évite de re-colorer quand on ne fait que bouger le curseur)

  function syncScroll () {
    pre.scrollTop = ta.scrollTop
    pre.scrollLeft = ta.scrollLeft
  }

  // Affiche le code coloré et met en évidence les délimiteurs associés à la position du curseur
  function render () {
    code.innerHTML = html
    const pos = ta.selectionStart
    if (ta.selectionEnd === pos) { // Rien si une sélection est en cours
      const txt = ta.value
      // On regarde d'abord le caractère avant le curseur, puis celui après
      for (const i of [pos - 1, pos]) {
        const c = txt.charAt(i) // charAt(-1) renvoie '' : sans effet en début de texte
        const pair = PAIRS.find(p => p.open === c || p.close === c)
        if (pair === undefined) continue
        const { valid, matches } = computeMatches(txt, pair.open, pair.close)
        if (!valid.has(i)) continue // Délimiteur échappé (\{ \[ \( ...)
        if (matches.has(i)) {
          wrapChar(code, i, 'mtg-brace-match')
          wrapChar(code, matches.get(i), 'mtg-brace-match')
        } else if (pair.error) wrapChar(code, i, 'mtg-brace-error')
        break
      }
    }
    syncScroll()
  }

  function refresh () {
    let txt = ta.value
    if (txt.endsWith('\n')) txt += ' ' // sinon la dernière ligne vide n'est pas rendue
    html = Prism.highlight(txt, Prism.languages.latex, 'latex')
    render()
  }

  // À appeler quand le textarea est dans le DOM (getComputedStyle renvoie du vide sinon)
  function syncStyle () {
    const cs = getComputedStyle(ta)
    props.forEach(p => { pre.style[p] = cs[p] })
    pre.style.width = ta.offsetWidth + 'px'
    pre.style.height = ta.offsetHeight + 'px'
    syncScroll()
  }

  // Saisie de l'utilisateur, défilement et déplacements du curseur
  ta.addEventListener('input', refresh)
  ta.addEventListener('scroll', syncScroll)
  ta.addEventListener('keyup', render)
  ta.addEventListener('mouseup', render)
  ta.addEventListener('focus', render)
  // Changements programmatiques : $(ta).val(x) fait ta.value = x, qui ne déclenche aucun événement
  const desc = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')
  Object.defineProperty(ta, 'value', {
    configurable: true,
    get () { return desc.get.call(this) },
    set (v) {
      desc.set.call(this, v)
      refresh()
      // TextButton positionne le curseur juste après l'affectation de la valeur :
      // on remet à jour les accolades mises en évidence une fois le curseur placé
      setTimeout(render, 0)
    }
  })

  return { container, refresh, syncStyle }
}