Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 239 lines 7.4 kB view raw
1/** 2 * MathBlock TipTap extension — LaTeX math equations for docs. 3 * 4 * Renders math using KaTeX. Supports both inline ($...$) and display 5 * ($$...$$) modes via a block node. The LaTeX source is stored as a 6 * node attribute and is fully E2EE-compatible (plain text in Yjs). 7 * 8 * UX: 9 * - Default state: rendered math output 10 * - Click/double-click: shows LaTeX editor 11 * - Live preview while editing 12 * - Errors show the KaTeX error message 13 * 14 * KaTeX is dynamically imported on first use. 15 */ 16 17import { Node, mergeAttributes } from '@tiptap/core'; 18 19declare module '@tiptap/core' { 20 interface Commands<ReturnType> { 21 mathBlock: { 22 insertMathBlock: (options?: { latex?: string }) => ReturnType; 23 }; 24 } 25} 26 27const DEFAULT_LATEX = 'E = mc^2'; 28 29let katexModule: typeof import('katex') | null = null; 30 31async function ensureKaTeX(): Promise<typeof import('katex')> { 32 if (katexModule) return katexModule; 33 katexModule = await import('katex'); 34 // Load KaTeX CSS from bundled asset (not CDN) to avoid SRI/supply-chain risk 35 if (!document.querySelector('link[href*="katex"]')) { 36 // @ts-ignore — Vite ?url suffix resolves to bundled asset path 37 const { default: cssUrl } = await import('katex/dist/katex.min.css?url'); 38 const link = document.createElement('link'); 39 link.rel = 'stylesheet'; 40 link.href = cssUrl as string; 41 document.head.appendChild(link); 42 } 43 return katexModule; 44} 45 46function renderKaTeX(latex: string, displayMode: boolean): string { 47 if (!katexModule) return ''; 48 try { 49 return katexModule.default.renderToString(latex, { 50 displayMode, 51 throwOnError: false, 52 output: 'html', 53 strict: false, 54 }); 55 } catch (err) { 56 return `<span class="math-error">${escapeHtml(String(err))}</span>`; 57 } 58} 59 60export const MathBlock = Node.create({ 61 name: 'mathBlock', 62 63 group: 'block', 64 atom: false, 65 selectable: true, 66 draggable: true, 67 68 addAttributes() { 69 return { 70 latex: { default: DEFAULT_LATEX }, 71 displayMode: { default: true }, 72 }; 73 }, 74 75 parseHTML() { 76 return [{ tag: 'div[data-math-block]' }]; 77 }, 78 79 renderHTML({ HTMLAttributes }) { 80 return ['div', mergeAttributes(HTMLAttributes, { 81 'data-math-block': '', 82 class: 'math-block', 83 }), 0]; 84 }, 85 86 addCommands() { 87 return { 88 insertMathBlock: 89 (options) => 90 ({ commands }) => { 91 return commands.insertContent({ 92 type: this.name, 93 attrs: { 94 latex: options?.latex || DEFAULT_LATEX, 95 displayMode: true, 96 }, 97 }); 98 }, 99 }; 100 }, 101 102 addNodeView() { 103 return ({ node, getPos, editor }) => { 104 const dom = document.createElement('div'); 105 dom.className = 'math-block-wrapper'; 106 dom.setAttribute('data-math-block', ''); 107 108 const output = document.createElement('div'); 109 output.className = 'math-block-output'; 110 dom.appendChild(output); 111 112 const toolbar = document.createElement('div'); 113 toolbar.className = 'math-block-toolbar'; 114 toolbar.style.display = 'none'; 115 116 const editBtn = document.createElement('button'); 117 editBtn.className = 'math-block-btn'; 118 editBtn.textContent = 'Edit'; 119 editBtn.title = 'Edit LaTeX'; 120 toolbar.appendChild(editBtn); 121 122 const modeBtn = document.createElement('button'); 123 modeBtn.className = 'math-block-btn'; 124 modeBtn.textContent = node.attrs.displayMode ? 'Display' : 'Inline'; 125 modeBtn.title = 'Toggle display/inline mode'; 126 toolbar.appendChild(modeBtn); 127 128 dom.appendChild(toolbar); 129 130 const codeEditor = document.createElement('textarea'); 131 codeEditor.className = 'math-block-editor'; 132 codeEditor.value = node.attrs.latex || DEFAULT_LATEX; 133 codeEditor.style.display = 'none'; 134 codeEditor.spellcheck = false; 135 dom.appendChild(codeEditor); 136 137 let editing = false; 138 let currentLatex = node.attrs.latex || DEFAULT_LATEX; 139 let displayMode = node.attrs.displayMode !== false; 140 141 async function renderMath() { 142 await ensureKaTeX(); 143 const html = renderKaTeX(currentLatex, displayMode); 144 output.innerHTML = html || `<span class="math-placeholder">Empty equation</span>`; 145 } 146 147 // Initial render 148 renderMath(); 149 150 // Show toolbar on hover 151 dom.addEventListener('mouseenter', () => { toolbar.style.display = ''; }); 152 dom.addEventListener('mouseleave', () => { if (!editing) toolbar.style.display = 'none'; }); 153 154 // Edit button 155 editBtn.addEventListener('click', (e) => { 156 e.stopPropagation(); 157 if (editing) { 158 // Save and close 159 editing = false; 160 if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } 161 codeEditor.style.display = 'none'; 162 editBtn.textContent = 'Edit'; 163 const pos = typeof getPos === 'function' ? getPos() : null; 164 if (pos !== null && pos !== undefined) { 165 editor.chain().focus() 166 .command(({ tr }) => { 167 tr.setNodeMarkup(pos, undefined, { latex: currentLatex, displayMode }); 168 return true; 169 }).run(); 170 } 171 } else { 172 editing = true; 173 codeEditor.style.display = ''; 174 codeEditor.value = currentLatex; 175 codeEditor.focus(); 176 editBtn.textContent = 'Done'; 177 } 178 }); 179 180 // Double-click to edit 181 dom.addEventListener('dblclick', () => { 182 if (!editing) editBtn.click(); 183 }); 184 185 // Mode toggle 186 modeBtn.addEventListener('click', (e) => { 187 e.stopPropagation(); 188 displayMode = !displayMode; 189 modeBtn.textContent = displayMode ? 'Display' : 'Inline'; 190 renderMath(); 191 const pos = typeof getPos === 'function' ? getPos() : null; 192 if (pos !== null && pos !== undefined) { 193 editor.chain().focus() 194 .command(({ tr }) => { 195 tr.setNodeMarkup(pos, undefined, { latex: currentLatex, displayMode }); 196 return true; 197 }).run(); 198 } 199 }); 200 201 // Live preview while editing 202 let debounceTimer: ReturnType<typeof setTimeout> | null = null; 203 codeEditor.addEventListener('input', () => { 204 currentLatex = codeEditor.value; 205 if (debounceTimer) clearTimeout(debounceTimer); 206 debounceTimer = setTimeout(() => renderMath(), 200); 207 }); 208 209 // Escape closes editor 210 codeEditor.addEventListener('keydown', (e) => { 211 if (e.key === 'Escape') { editBtn.click(); e.preventDefault(); } 212 e.stopPropagation(); 213 }); 214 215 return { 216 dom, 217 update(updatedNode) { 218 if (updatedNode.type.name !== 'mathBlock') return false; 219 currentLatex = updatedNode.attrs.latex || DEFAULT_LATEX; 220 displayMode = updatedNode.attrs.displayMode !== false; 221 if (!editing) codeEditor.value = currentLatex; 222 modeBtn.textContent = displayMode ? 'Display' : 'Inline'; 223 renderMath(); 224 return true; 225 }, 226 stopEvent(event: Event) { 227 return editing && event.target === codeEditor; 228 }, 229 destroy() { 230 if (debounceTimer) clearTimeout(debounceTimer); 231 }, 232 }; 233 }; 234 }, 235}); 236 237function escapeHtml(s: string): string { 238 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 239}