mirror of https://github.com/requarks/wiki
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.
238 lines
7.9 KiB
238 lines
7.9 KiB
import { LitElement, html, css, unsafeCSS } from 'lit'
|
|
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
|
|
import { renderToString } from 'katex'
|
|
import katexCss from 'katex/dist/katex.min.css'
|
|
/*
|
|
mhchem, imported for its side effect: the contrib module reaches into the same katex instance this
|
|
file imports and defines `\ce`, `\pu` and the machinery behind them as macros. There is nothing to
|
|
call and nothing to configure — the import is the installation, which is why it has no binding.
|
|
|
|
It is the KaTeX port of the same extension the MathJax block loads, so `\ce{CO2 + C -> 2 CO}` means
|
|
the same thing in both blocks.
|
|
*/
|
|
import 'katex/contrib/mhchem'
|
|
import { DarkMode } from '../shared/theme.js'
|
|
|
|
/*
|
|
KaTeX's stylesheet, split in two.
|
|
|
|
A `@font-face` is a document-level thing: the rule declares a family, and a stylesheet inside a
|
|
shadow root is not where the browser looks for one. So the faces go to the document — once, when
|
|
this module loads, however many formulas the page turns out to hold — and everything else goes into
|
|
the shadow root with the component, where the class names KaTeX writes into its markup are.
|
|
|
|
The `url()` in each face is already a data URI by this point: see `cssAsString` in
|
|
`rollup.config.mjs` for why a block cannot leave its fonts as files.
|
|
*/
|
|
const FONT_FACE_RULE = /@font-face\{[^{}]*\}/g
|
|
const KATEX_FONT_FACES = (katexCss.match(FONT_FACE_RULE) ?? []).join('')
|
|
const KATEX_RULES = katexCss.replace(FONT_FACE_RULE, '')
|
|
|
|
const fontSheet = new CSSStyleSheet()
|
|
fontSheet.replaceSync(KATEX_FONT_FACES)
|
|
document.adoptedStyleSheets = [...document.adoptedStyleSheets, fontSheet]
|
|
|
|
/**
|
|
* Block KaTeX
|
|
*/
|
|
export class BlockKatexElement 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: 'katex',
|
|
name: 'KaTeX',
|
|
description:
|
|
'Typesets a TeX formula with KaTeX, including chemical equations written with mhchem — \\ce{} and \\pu{}.',
|
|
icon: 'math',
|
|
/*
|
|
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 [
|
|
// -> KaTeX first, so the rules below win where the two touch the same thing
|
|
unsafeCSS(KATEX_RULES),
|
|
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. 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 block owns its spacing; KaTeX's own 1em above and below would double it up */
|
|
.drawing .katex-display {
|
|
margin: 0;
|
|
}
|
|
|
|
.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
|
|
_markup: { state: true },
|
|
_error: { state: true }
|
|
}
|
|
}
|
|
|
|
constructor() {
|
|
super()
|
|
this.caption = ''
|
|
this.align = 'center'
|
|
this._markup = ''
|
|
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 {
|
|
this._markup = renderToString(source, {
|
|
displayMode: true,
|
|
/*
|
|
Both output forms: the drawing a reader sees, and a MathML copy of the same expression that
|
|
KaTeX hides and a screen reader announces. That is why this block writes no aria-label — the
|
|
expression itself is in the markup, read as mathematics rather than as TeX source.
|
|
*/
|
|
output: 'htmlAndMathml',
|
|
/*
|
|
Handing the error on rather than drawing it: KaTeX's other answer to bad input is to print
|
|
the source in red where the formula should be, which says nothing about what is wrong with
|
|
it. Thrown, it reaches the catch below and the panel in `render`, with the position KaTeX
|
|
found the problem at.
|
|
*/
|
|
throwOnError: true,
|
|
/*
|
|
Macros are the one piece of state a render leaves behind: `\gdef` writes into this object,
|
|
and KaTeX would carry the definition into whatever it typesets next if every block shared
|
|
one. A formula defines macros for itself.
|
|
*/
|
|
macros: {}
|
|
// -> `trust` is left at its default. It gates \href, \url and \includegraphics, which put a
|
|
// link or a remote image into the page from inside TeX — not what a formula is for, and
|
|
// the same reason the MathJax block leaves out the `html` package.
|
|
})
|
|
this._error = ''
|
|
} catch (err) {
|
|
this._markup = ''
|
|
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">${unsafeHTML(this._markup)}</div>
|
|
${this.caption ? html`<div class="caption">${this.caption}</div>` : null}
|
|
</div>
|
|
`
|
|
}
|
|
}
|
|
|
|
window.customElements.define('block-katex', BlockKatexElement)
|