You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/blocks/block-mathjax/component.js

309 lines
11 KiB

import { LitElement, html, css } from 'lit'
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
import { mathjax } from '@mathjax/src/js/mathjax.js'
import { TeX } from '@mathjax/src/js/input/tex.js'
import { SVG } from '@mathjax/src/js/output/svg.js'
import { liteAdaptor } from '@mathjax/src/js/adaptors/liteAdaptor.js'
import { RegisterHTMLHandler } from '@mathjax/src/js/handlers/html.js'
import { MathJaxNewcmFont } from '@mathjax/mathjax-newcm-font/js/svg.js'
import { MathJaxMhchemFontExtension } from '@mathjax/mathjax-mhchem-font-extension/js/svg.js'
/*
Every TeX package the block understands, imported for its side effect: a configuration registers
itself under its name, and `PACKAGES` below is what then switches it on.
This is the set MathJax's own "all packages" bundle carries, less three of them. `html` is left out
because it exists to put HTML into the page from inside TeX — a link, a class, a style attribute —
which is not what a formula is for. `noerrors` and `noundefined` are left out because both answer a
mistake by drawing something: the unreadable source in place of the formula, or a black box where a
macro should have been. Without them the error reaches this file, which has a panel to say so in.
Nothing here loads anything at run time. `require` and `autoload` are absent for that reason: both
fetch a package the moment TeX asks for one, which cannot work in a bundle — the block is a single
file served from /_blocks, with no MathJax install behind it to fetch from.
*/
import '@mathjax/src/js/input/tex/base/BaseConfiguration.js'
import '@mathjax/src/js/input/tex/action/ActionConfiguration.js'
import '@mathjax/src/js/input/tex/ams/AmsConfiguration.js'
import '@mathjax/src/js/input/tex/amscd/AmsCdConfiguration.js'
import '@mathjax/src/js/input/tex/bbox/BboxConfiguration.js'
import '@mathjax/src/js/input/tex/boldsymbol/BoldsymbolConfiguration.js'
import '@mathjax/src/js/input/tex/braket/BraketConfiguration.js'
import '@mathjax/src/js/input/tex/bussproofs/BussproofsConfiguration.js'
import '@mathjax/src/js/input/tex/cancel/CancelConfiguration.js'
import '@mathjax/src/js/input/tex/cases/CasesConfiguration.js'
import '@mathjax/src/js/input/tex/centernot/CenternotConfiguration.js'
import '@mathjax/src/js/input/tex/color/ColorConfiguration.js'
import '@mathjax/src/js/input/tex/colortbl/ColortblConfiguration.js'
import '@mathjax/src/js/input/tex/empheq/EmpheqConfiguration.js'
import '@mathjax/src/js/input/tex/enclose/EncloseConfiguration.js'
import '@mathjax/src/js/input/tex/extpfeil/ExtpfeilConfiguration.js'
import '@mathjax/src/js/input/tex/gensymb/GensymbConfiguration.js'
import '@mathjax/src/js/input/tex/mathtools/MathtoolsConfiguration.js'
import '@mathjax/src/js/input/tex/mhchem/MhchemConfiguration.js'
import '@mathjax/src/js/input/tex/newcommand/NewcommandConfiguration.js'
import '@mathjax/src/js/input/tex/physics/PhysicsConfiguration.js'
import '@mathjax/src/js/input/tex/textcomp/TextcompConfiguration.js'
import '@mathjax/src/js/input/tex/textmacros/TextMacrosConfiguration.js'
import '@mathjax/src/js/input/tex/unicode/UnicodeConfiguration.js'
import '@mathjax/src/js/input/tex/upgreek/UpgreekConfiguration.js'
import '@mathjax/src/js/input/tex/verb/VerbConfiguration.js'
import { DarkMode } from '../shared/theme.js'
const PACKAGES = [
'base',
'action',
'ams',
'amscd',
'bbox',
'boldsymbol',
'braket',
'bussproofs',
'cancel',
'cases',
'centernot',
'color',
'colortbl',
'empheq',
'enclose',
'extpfeil',
'gensymb',
'mathtools',
'mhchem',
'newcommand',
'physics',
'textcomp',
'textmacros',
'unicode',
'upgreek',
'verb'
]
/**
* MathJax, set up once for the page.
*
* Off the document entirely: the SVG output measures nothing in the DOM — it has the metrics of every
* glyph in the font it draws with — so a formula can be typeset against a document MathJax makes up
* for itself and handed back as markup. That is what makes it usable from inside a shadow root, which
* MathJax has no notion of and where its own stylesheet in the page would not reach.
*/
const adaptor = liteAdaptor()
RegisterHTMLHandler(adaptor)
const output = new SVG({
fontData: MathJaxNewcmFont,
/*
Each formula carries its own glyph definitions. The alternative, one cache for the page, is a
single hidden `svg` in the document that every formula points into — and a reference from inside a
shadow root does not resolve to it, so every letter would come out blank.
*/
fontCache: 'local'
})
/*
mhchem's bonds, arrows and brackets are glyphs of their own, in a font variant the text font has no
reason to carry. Added here rather than fetched: MathJax's own build loads this on demand the first
time a `\ce` turns up, which is the one thing a bundled block cannot do.
*/
output.font.addExtension(MathJaxMhchemFontExtension)
const document_ = mathjax.document('', {
InputJax: new TeX({
packages: PACKAGES,
// -> Handing the error on rather than drawing it: see the panel in `render`
formatError: (jax, err) => {
throw err
}
}),
OutputJax: output
})
/**
* Block MathJax
*/
export class BlockMathjaxElement extends LitElement {
/**
* Metadata for the admin area and the editor's block picker. Collected at build time into
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
* plain literals. See `props` in `block-index` for what the picker does with that list.
*/
static definition = {
block: 'mathjax',
name: 'MathJax',
description:
'Typesets a TeX formula, including chemical equations written with mhchem — \\ce{} and \\pu{}.',
icon: 'sigma',
/*
Fenced, and not as a nicety: TeX is made of the characters markdown reads as its own. A lone
backslash goes missing, `_` and `^` open emphasis, `\\` at the end of a line is a break, and the
typographer rewrites quotes and dashes inside the source. Inside a fence it arrives as typed.
*/
template: `\`\`\`latex
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
\`\`\``,
props: [
{
name: 'caption',
type: 'string',
label: 'Caption',
hint: 'Shown under the formula.'
},
{
name: 'align',
type: 'select',
label: 'Alignment',
options: ['center', 'left'],
default: 'center'
}
]
}
static get styles() {
return css`
:host {
display: block;
}
/* -> The gap below the block. On this element rather than :host: see block-index. */
.formula,
.error {
margin-bottom: 16px;
}
.formula {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.formula.is-left {
align-items: flex-start;
}
/*
A formula wider than the column scrolls rather than shrinks, the way a display equation in the
text does — see .katex-display in css/_page-contents.scss. Shrinking is the wrong answer for
something read symbol by symbol: a long derivation would end up a grey smear.
*/
.drawing {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
/* -> Room for the scrollbar to appear without it sitting on the descenders */
padding: 0.2em 0;
}
/*
The drawing takes the colour of the text around it: MathJax paints its glyphs in currentColor,
so dark mode needs nothing here — unlike a block that picks its own colours.
*/
svg {
display: block;
}
.caption {
color: #424242;
font-size: 0.8em;
text-align: center;
}
:host([dark]) .caption {
color: rgba(255, 255, 255, 0.7);
}
.error {
color: var(--q-negative, #c10015);
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
border-radius: 5px;
padding: 1rem;
white-space: pre-wrap;
}
`
}
static get properties() {
return {
/**
* Text shown under the formula
* @type {string}
*/
caption: { type: String },
/**
* Where the formula sits in the column, `center` or `left`
* @type {string}
*/
align: { type: String },
// Internal Properties
_svg: { state: true },
_error: { state: true }
}
}
constructor() {
super()
this.caption = ''
this.align = 'center'
this._svg = ''
this._error = ''
// -> Puts `dark` on this element for the styles above to key off
this._darkMode = new DarkMode(this)
}
/**
* Typeset the source, or say why it could not be.
*/
_typeset(source, fenced) {
try {
const container = document_.convert(source, { display: true })
const drawing = adaptor.firstChild(container)
/*
The formula named for a reader who cannot see it. MathJax's own answer to this is a MathML
copy of the expression alongside the drawing, which needs its stylesheet in the page to stay
hidden and its speech engine to read well — neither of which a block in a shadow root has. The
source is what is left, and it is what the author wrote: imperfectly read aloud, but the
drawing already carries role="img", and an image with no name at all is worse.
*/
adaptor.setAttribute(drawing, 'aria-label', source)
this._svg = adaptor.outerHTML(drawing)
this._error = ''
} catch (err) {
this._svg = ''
this._error = `This formula could not be typeset: ${err.message ?? err}`
if (!fenced) {
this._error +=
'\n\nThe source has to go inside a fenced code block, or markdown rewrites it before this block sees it.'
}
}
}
firstUpdated() {
/*
The source is the block's body, taken from the fence markdown left behind. `textContent` is what
undoes the escaping that put `&` and `<` in the markup, and gives back what was typed.
*/
const fence = this.querySelector('pre')
const source = ((fence ?? this).textContent ?? '').trim()
if (!source) {
this._error =
'This formula is empty. Its TeX source goes in the body of the block, inside a fenced code block.'
return
}
this._typeset(source, Boolean(fence))
}
render() {
if (this._error) {
return html`<div class="error">${this._error}</div>`
}
return html`
<div class="formula ${this.align === 'left' ? 'is-left' : ''}">
<div class="drawing">${unsafeSVG(this._svg)}</div>
${this.caption ? html`<div class="caption">${this.caption}</div>` : null}
</div>
`
}
}
window.customElements.define('block-mathjax', BlockMathjaxElement)