// 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 }
}