Spaces:
Running
Running
| // 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: /(?<!\$)\$(?![\s$])([^$\n]*[^\s$])\$$/, | |
| handler: ({ state, range, match, chain }) => { | |
| 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 | |
| } | |