// LaTeX math: an inline atom (`$x^2$`) and a display block (`$$...$$`), both // holding their source in a `latex` attribute and typeset with KaTeX. // // KaTeX is ~270KB and most documents have no math, so it is NOT in the main // bundle: the first math node on the page pulls it in, and every node renders // its source as a placeholder until it lands. import { Node, InputRule } from '@tiptap/core' import { TextSelection } from '@tiptap/pm/state' import { el } from './common.js' // --- the lazy KaTeX loader --------------------------------------------------- let katexPromise = null function assetVersion() { return window.__BOOT?.build_id ? `?v=${window.__BOOT.build_id}` : '' } export function loadKatex() { if (window.katex) return Promise.resolve(window.katex) if (katexPromise) return katexPromise katexPromise = new Promise((resolve, reject) => { const v = assetVersion() if (!document.getElementById('katex-css')) { const css = el('link', { id: 'katex-css', rel: 'stylesheet', href: `/katex.css${v}` }) document.head.appendChild(css) } const script = document.createElement('script') script.src = `/katex.js${v}` script.onload = () => (window.katex ? resolve(window.katex) : reject(new Error('katex did not load'))) script.onerror = () => reject(new Error('katex failed to load')) document.head.appendChild(script) }) // a failed fetch must not poison every later attempt — the nodes stay // readable as source, and the next one retries katexPromise.catch(() => { katexPromise = null }) return katexPromise } // Typeset `latex` into `target`. Renders the source as a placeholder first, so // the formula is always readable — including when KaTeX never arrives. export function renderMath(target, latex, { display = false } = {}) { const src = latex || '' target.textContent = display ? `$$${src}$$` : `$${src}$` target.classList.add('math-raw') if (!src.trim()) { target.classList.remove('math-raw') target.textContent = display ? '$$ $$' : 'empty formula' target.classList.add('math-empty') return } loadKatex() .then(katex => { // throwOnError:false makes KaTeX render bad input in red rather than // blowing up the editor; the message goes in the tooltip katex.render(src, target, { displayMode: display, throwOnError: false, errorColor: 'var(--danger)', output: 'html' }) target.classList.remove('math-raw') }) .catch(() => {}) // placeholder text stands } // --- shared node behaviour -------------------------------------------------- // A formula inserted by the user (toolbar, `$$`, `$…$`) should open its source // editor immediately — an empty formula is useless until it has one. But a // NodeView is also created whenever ProseMirror re-renders a node: after an // undo, an accepted suggestion, a remote edit. Auto-opening on those would yank // the caret out of whatever the user was typing, so the intent is recorded at // the insertion site instead of guessed at render time. let insertedAt = 0 function markUserInsert() { insertedAt = Date.now() } function isUserInsert() { return Date.now() - insertedAt < 400 } // Clicking a formula opens its source in a plain input; Enter or blur commits, // Escape reverts. Editing the source beats a WYSIWYG formula editor here: the // source is what round-trips to markdown and what agents propose. function mathNodeView({ display }) { return ({ node, getPos, editor }) => { let current = node let editing = false const wrap = el(display ? 'div' : 'span', { class: display ? 'math-block' : 'math-inline', contenteditable: 'false', }) const view = el('span', { class: 'math-view' }) const input = el('input', { class: 'math-src', spellcheck: 'false' }) input.type = 'text' wrap.append(view, input) const paint = () => renderMath(view, current.attrs.latex, { display }) // Leaving the source editor must land the caret just AFTER the formula. // Without this the selection stays on the atom itself, and the next Enter // splits the paragraph at the formula instead of starting a new line. const caretAfter = tr => { const pos = typeof getPos === 'function' ? getPos() : null if (pos == null) return tr const size = tr.doc.nodeAt(pos)?.nodeSize ?? 1 try { return tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(pos + size, tr.doc.content.size)))) } catch { return tr } } const stopEditing = ({ save }) => { if (!editing) return editing = false wrap.classList.remove('editing') const latex = input.value.trim() const pos = typeof getPos === 'function' ? getPos() : null let tr = editor.view.state.tr if (save && pos != null && latex !== current.attrs.latex) { tr = tr.setNodeMarkup(pos, undefined, { ...current.attrs, latex }) } editor.view.dispatch(caretAfter(tr)) paint() } const startEditing = () => { if (editing || !editor.isEditable) return editing = true wrap.classList.add('editing') input.value = current.attrs.latex || '' // width follows the source so the line does not jump around while typing const size = () => (input.size = Math.max(6, Math.min(input.value.length + 2, 60))) size() input.oninput = size input.focus() input.select() } // Editing opens on DOUBLE click. A single click has to stay with // ProseMirror: it is how you select the formula (to comment on it) and how a // drag across the line selects text through it. Opening the editor on a // single click stole both — a drag that happened to end on a formula lost // the selection, and the formula could not be selected at all. view.addEventListener('dblclick', e => { if (editing) return e.preventDefault() e.stopPropagation() startEditing() }) view.title = 'Double-click to edit the LaTeX' input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault() stopEditing({ save: true }) editor.view.focus() } else if (e.key === 'Escape') { e.preventDefault() stopEditing({ save: false }) editor.view.focus() } e.stopPropagation() // the document's own shortcuts must not fire here }) input.addEventListener('blur', () => stopEditing({ save: true })) paint() if (!current.attrs.latex && editor.isEditable && isUserInsert()) { setTimeout(() => { if (!current.attrs.latex && isUserInsert()) startEditing() }, 0) } return { dom: wrap, // the input is not part of the document — ProseMirror must keep out ignoreMutation: () => true, stopEvent: () => editing, selectNode() { wrap.classList.add('selected') }, deselectNode() { wrap.classList.remove('selected') }, update(updated) { if (updated.type.name !== current.type.name) return false const changed = updated.attrs.latex !== current.attrs.latex current = updated if (changed && !editing) paint() return true }, destroy() { input.oninput = null }, } } } // --- the nodes -------------------------------------------------------------- export const MathInline = Node.create({ name: 'mathInline', group: 'inline', inline: true, atom: true, selectable: true, addAttributes() { return { latex: { default: '' } } }, parseHTML() { return [{ tag: 'span[data-math]', getAttrs: n => ({ latex: n.getAttribute('data-latex') || '' }) }] }, renderHTML({ HTMLAttributes }) { return ['span', { 'data-math': 'inline', 'data-latex': HTMLAttributes.latex || '' }] }, addKeyboardShortcuts() { return { // a selected formula opens its source with Enter — double-click is not the // only way in, and this is the only way in from the keyboard Enter: () => { const { selection } = this.editor.state if (selection.node?.type?.name !== this.name) return false const dom = this.editor.view.nodeDOM(selection.from) const target = dom?.querySelector?.('.math-view') if (!target) return false target.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) return true }, } }, addNodeView() { return mathNodeView({ display: false }) }, addCommands() { return { insertMathInline: (latex = '') => ({ chain }) => { if (!latex) markUserInsert() return chain().insertContent({ type: 'mathInline', attrs: { latex } }).run() }, } }, addInputRules() { return [ // typing `$x^2$` turns into a formula as soon as the closing $ lands. // Same strictness as the markdown tokenizer, so money is left alone. new InputRule({ find: /(? { const latex = match[1] if (!latex.trim()) return chain() .deleteRange(range) .insertContent({ type: 'mathInline', attrs: { latex } }) .run() void state }, }), ] }, }) export const MathBlock = Node.create({ name: 'mathBlock', group: 'block', atom: true, selectable: true, draggable: true, addAttributes() { return { latex: { default: '' } } }, parseHTML() { return [{ tag: 'div[data-math-block]', getAttrs: n => ({ latex: n.getAttribute('data-latex') || '' }) }] }, renderHTML({ HTMLAttributes }) { return ['div', { 'data-math-block': '', 'data-latex': HTMLAttributes.latex || '' }] }, addKeyboardShortcuts() { return { // a selected formula opens its source with Enter — double-click is not the // only way in, and this is the only way in from the keyboard Enter: () => { const { selection } = this.editor.state if (selection.node?.type?.name !== this.name) return false const dom = this.editor.view.nodeDOM(selection.from) const target = dom?.querySelector?.('.math-view') if (!target) return false target.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) return true }, } }, addNodeView() { return mathNodeView({ display: true }) }, addCommands() { return { insertMathBlock: (latex = '') => ({ chain }) => { if (!latex) markUserInsert() return chain().insertContent({ type: 'mathBlock', attrs: { latex } }).run() }, } }, addInputRules() { return [ // `$$` on an empty line opens a display formula ready to type into new InputRule({ find: /^\$\$$/, handler: ({ range, chain }) => { markUserInsert() chain().deleteRange(range).insertContent({ type: 'mathBlock', attrs: { latex: '' } }).run() }, }), ] }, }) // Preview/insertion panels build plain HTML rather than a live editor, so they // need a detached element that typesets itself once KaTeX is around. export function mathPreviewEl(latex, { display = false } = {}) { const wrap = el(display ? 'div' : 'span', { class: display ? 'math-block' : 'math-inline' }) const view = el('span', { class: 'math-view' }) wrap.appendChild(view) renderMath(view, latex, { display }) return wrap }