cowrite / client /src /math.js
lvwerra's picture
lvwerra HF Staff
Math interaction: double-click to edit, single click selects, selection drags through a formula, comment box keeps the selection and quotes the source
f361450 verified
Raw
History Blame Contribute Delete
11.5 kB
// 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
}