/** * MathBlock TipTap extension — LaTeX math equations for docs. * * Renders math using KaTeX. Supports both inline ($...$) and display * ($$...$$) modes via a block node. The LaTeX source is stored as a * node attribute and is fully E2EE-compatible (plain text in Yjs). * * UX: * - Default state: rendered math output * - Click/double-click: shows LaTeX editor * - Live preview while editing * - Errors show the KaTeX error message * * KaTeX is dynamically imported on first use. */ import { Node, mergeAttributes } from '@tiptap/core'; declare module '@tiptap/core' { interface Commands { mathBlock: { insertMathBlock: (options?: { latex?: string }) => ReturnType; }; } } const DEFAULT_LATEX = 'E = mc^2'; let katexModule: typeof import('katex') | null = null; async function ensureKaTeX(): Promise { if (katexModule) return katexModule; katexModule = await import('katex'); // Load KaTeX CSS from bundled asset (not CDN) to avoid SRI/supply-chain risk if (!document.querySelector('link[href*="katex"]')) { // @ts-ignore — Vite ?url suffix resolves to bundled asset path const { default: cssUrl } = await import('katex/dist/katex.min.css?url'); const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = cssUrl as string; document.head.appendChild(link); } return katexModule; } function renderKaTeX(latex: string, displayMode: boolean): string { if (!katexModule) return ''; try { return katexModule.default.renderToString(latex, { displayMode, throwOnError: false, output: 'html', strict: false, }); } catch (err) { return `${escapeHtml(String(err))}`; } } export const MathBlock = Node.create({ name: 'mathBlock', group: 'block', atom: false, selectable: true, draggable: true, addAttributes() { return { latex: { default: DEFAULT_LATEX }, displayMode: { default: true }, }; }, parseHTML() { return [{ tag: 'div[data-math-block]' }]; }, renderHTML({ HTMLAttributes }) { return ['div', mergeAttributes(HTMLAttributes, { 'data-math-block': '', class: 'math-block', }), 0]; }, addCommands() { return { insertMathBlock: (options) => ({ commands }) => { return commands.insertContent({ type: this.name, attrs: { latex: options?.latex || DEFAULT_LATEX, displayMode: true, }, }); }, }; }, addNodeView() { return ({ node, getPos, editor }) => { const dom = document.createElement('div'); dom.className = 'math-block-wrapper'; dom.setAttribute('data-math-block', ''); const output = document.createElement('div'); output.className = 'math-block-output'; dom.appendChild(output); const toolbar = document.createElement('div'); toolbar.className = 'math-block-toolbar'; toolbar.style.display = 'none'; const editBtn = document.createElement('button'); editBtn.className = 'math-block-btn'; editBtn.textContent = 'Edit'; editBtn.title = 'Edit LaTeX'; toolbar.appendChild(editBtn); const modeBtn = document.createElement('button'); modeBtn.className = 'math-block-btn'; modeBtn.textContent = node.attrs.displayMode ? 'Display' : 'Inline'; modeBtn.title = 'Toggle display/inline mode'; toolbar.appendChild(modeBtn); dom.appendChild(toolbar); const codeEditor = document.createElement('textarea'); codeEditor.className = 'math-block-editor'; codeEditor.value = node.attrs.latex || DEFAULT_LATEX; codeEditor.style.display = 'none'; codeEditor.spellcheck = false; dom.appendChild(codeEditor); let editing = false; let currentLatex = node.attrs.latex || DEFAULT_LATEX; let displayMode = node.attrs.displayMode !== false; async function renderMath() { await ensureKaTeX(); const html = renderKaTeX(currentLatex, displayMode); output.innerHTML = html || `Empty equation`; } // Initial render renderMath(); // Show toolbar on hover dom.addEventListener('mouseenter', () => { toolbar.style.display = ''; }); dom.addEventListener('mouseleave', () => { if (!editing) toolbar.style.display = 'none'; }); // Edit button editBtn.addEventListener('click', (e) => { e.stopPropagation(); if (editing) { // Save and close editing = false; if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } codeEditor.style.display = 'none'; editBtn.textContent = 'Edit'; const pos = typeof getPos === 'function' ? getPos() : null; if (pos !== null && pos !== undefined) { editor.chain().focus() .command(({ tr }) => { tr.setNodeMarkup(pos, undefined, { latex: currentLatex, displayMode }); return true; }).run(); } } else { editing = true; codeEditor.style.display = ''; codeEditor.value = currentLatex; codeEditor.focus(); editBtn.textContent = 'Done'; } }); // Double-click to edit dom.addEventListener('dblclick', () => { if (!editing) editBtn.click(); }); // Mode toggle modeBtn.addEventListener('click', (e) => { e.stopPropagation(); displayMode = !displayMode; modeBtn.textContent = displayMode ? 'Display' : 'Inline'; renderMath(); const pos = typeof getPos === 'function' ? getPos() : null; if (pos !== null && pos !== undefined) { editor.chain().focus() .command(({ tr }) => { tr.setNodeMarkup(pos, undefined, { latex: currentLatex, displayMode }); return true; }).run(); } }); // Live preview while editing let debounceTimer: ReturnType | null = null; codeEditor.addEventListener('input', () => { currentLatex = codeEditor.value; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => renderMath(), 200); }); // Escape closes editor codeEditor.addEventListener('keydown', (e) => { if (e.key === 'Escape') { editBtn.click(); e.preventDefault(); } e.stopPropagation(); }); return { dom, update(updatedNode) { if (updatedNode.type.name !== 'mathBlock') return false; currentLatex = updatedNode.attrs.latex || DEFAULT_LATEX; displayMode = updatedNode.attrs.displayMode !== false; if (!editing) codeEditor.value = currentLatex; modeBtn.textContent = displayMode ? 'Display' : 'Inline'; renderMath(); return true; }, stopEvent(event: Event) { return editing && event.target === codeEditor; }, destroy() { if (debounceTimer) clearTimeout(debounceTimer); }, }; }; }, }); function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); }