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

Configure Feed

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

Merge pull request 'feat: KaTeX math equation blocks for docs (0.42.0)' (#377) from feat/katex-math into main

scott 9ac50d26 e9226d9a

+308 -3
+6
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.42.0] — 2026-04-14 11 + 12 + ### Added 13 + - Docs: KaTeX math equation blocks with live preview, display/inline toggle, slash command (#629) 14 + - KaTeX dependency (v0.16.45) with dynamic import and CSS auto-loading 15 + 10 16 ## [0.41.0] — 2026-04-14 11 17 12 18 ### Added
+3 -2
package-lock.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.37.0", 3 + "version": "0.41.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tools", 9 - "version": "0.37.0", 9 + "version": "0.41.0", 10 10 "dependencies": { 11 11 "@tiptap/core": "^2.11.0", 12 12 "@tiptap/extension-code-block-lowlight": "^2.27.2", ··· 39 39 "exceljs": "^4.4.0", 40 40 "express": "^4.21.0", 41 41 "html2pdf.js": "^0.14.0", 42 + "katex": "^0.16.45", 42 43 "lib0": "^0.2.99", 43 44 "lowlight": "^3.3.0", 44 45 "mammoth": "^1.12.0",
+2 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.41.0", 3 + "version": "0.42.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js", ··· 48 48 "exceljs": "^4.4.0", 49 49 "express": "^4.21.0", 50 50 "html2pdf.js": "^0.14.0", 51 + "katex": "^0.16.45", 51 52 "lib0": "^0.2.99", 52 53 "lowlight": "^3.3.0", 53 54 "mammoth": "^1.12.0",
+47
src/css/app.css
··· 11879 11879 .analytics-stat-label { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; } 11880 11880 .analytics-chart { margin: var(--space-sm) 0; padding: var(--space-sm); background: var(--color-surface); border-radius: var(--radius-sm, 4px); } 11881 11881 .analytics-chart h4 { margin: 0 0 var(--space-xs); font-size: 0.85rem; } 11882 + 11883 + /* ── Math Block (KaTeX) ───────────────────────────────────────── */ 11884 + 11885 + .math-block-wrapper { 11886 + position: relative; 11887 + margin: 1em 0; 11888 + padding: 12px 16px; 11889 + border: 1px solid var(--color-border); 11890 + border-radius: var(--radius-sm, 4px); 11891 + background: var(--color-surface); 11892 + cursor: pointer; 11893 + } 11894 + .math-block-wrapper:hover { border-color: var(--color-text-muted); } 11895 + .math-block-output { min-height: 1.5em; overflow-x: auto; } 11896 + .math-block-output .katex-display { margin: 0.5em 0; } 11897 + .math-placeholder { color: var(--color-text-faint); font-style: italic; font-size: 0.85rem; } 11898 + .math-error { color: #c33; font-size: 0.8rem; font-family: var(--font-mono, monospace); } 11899 + .math-block-toolbar { 11900 + position: absolute; 11901 + top: 4px; 11902 + right: 4px; 11903 + display: flex; 11904 + gap: 4px; 11905 + } 11906 + .math-block-btn { 11907 + padding: 2px 8px; 11908 + border: 1px solid var(--color-border); 11909 + border-radius: 3px; 11910 + background: var(--color-bg); 11911 + color: var(--color-text-muted); 11912 + font-size: 0.72rem; 11913 + cursor: pointer; 11914 + } 11915 + .math-block-btn:hover { background: var(--color-surface-alt); color: var(--color-text); } 11916 + .math-block-editor { 11917 + width: 100%; 11918 + min-height: 60px; 11919 + margin-top: 8px; 11920 + padding: 8px; 11921 + border: 1px solid var(--color-border); 11922 + border-radius: 3px; 11923 + background: var(--color-bg); 11924 + color: var(--color-text); 11925 + font-family: var(--font-mono, monospace); 11926 + font-size: 0.85rem; 11927 + resize: vertical; 11928 + }
+237
src/docs/extensions/math-block.ts
··· 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 + 17 + import { Node, mergeAttributes } from '@tiptap/core'; 18 + 19 + declare module '@tiptap/core' { 20 + interface Commands<ReturnType> { 21 + mathBlock: { 22 + insertMathBlock: (options?: { latex?: string }) => ReturnType; 23 + }; 24 + } 25 + } 26 + 27 + const DEFAULT_LATEX = 'E = mc^2'; 28 + 29 + let katexModule: typeof import('katex') | null = null; 30 + 31 + async function ensureKaTeX(): Promise<typeof import('katex')> { 32 + if (katexModule) return katexModule; 33 + katexModule = await import('katex'); 34 + // Load KaTeX CSS if not already loaded 35 + if (!document.querySelector('link[href*="katex"]')) { 36 + const link = document.createElement('link'); 37 + link.rel = 'stylesheet'; 38 + link.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css'; 39 + link.crossOrigin = 'anonymous'; 40 + document.head.appendChild(link); 41 + } 42 + return katexModule; 43 + } 44 + 45 + function renderKaTeX(latex: string, displayMode: boolean): string { 46 + if (!katexModule) return ''; 47 + try { 48 + return katexModule.default.renderToString(latex, { 49 + displayMode, 50 + throwOnError: false, 51 + output: 'html', 52 + strict: false, 53 + }); 54 + } catch (err) { 55 + return `<span class="math-error">${escapeHtml(String(err))}</span>`; 56 + } 57 + } 58 + 59 + export const MathBlock = Node.create({ 60 + name: 'mathBlock', 61 + 62 + group: 'block', 63 + atom: false, 64 + selectable: true, 65 + draggable: true, 66 + 67 + addAttributes() { 68 + return { 69 + latex: { default: DEFAULT_LATEX }, 70 + displayMode: { default: true }, 71 + }; 72 + }, 73 + 74 + parseHTML() { 75 + return [{ tag: 'div[data-math-block]' }]; 76 + }, 77 + 78 + renderHTML({ HTMLAttributes }) { 79 + return ['div', mergeAttributes(HTMLAttributes, { 80 + 'data-math-block': '', 81 + class: 'math-block', 82 + }), 0]; 83 + }, 84 + 85 + addCommands() { 86 + return { 87 + insertMathBlock: 88 + (options) => 89 + ({ commands }) => { 90 + return commands.insertContent({ 91 + type: this.name, 92 + attrs: { 93 + latex: options?.latex || DEFAULT_LATEX, 94 + displayMode: true, 95 + }, 96 + }); 97 + }, 98 + }; 99 + }, 100 + 101 + addNodeView() { 102 + return ({ node, getPos, editor }) => { 103 + const dom = document.createElement('div'); 104 + dom.className = 'math-block-wrapper'; 105 + dom.setAttribute('data-math-block', ''); 106 + 107 + const output = document.createElement('div'); 108 + output.className = 'math-block-output'; 109 + dom.appendChild(output); 110 + 111 + const toolbar = document.createElement('div'); 112 + toolbar.className = 'math-block-toolbar'; 113 + toolbar.style.display = 'none'; 114 + 115 + const editBtn = document.createElement('button'); 116 + editBtn.className = 'math-block-btn'; 117 + editBtn.textContent = 'Edit'; 118 + editBtn.title = 'Edit LaTeX'; 119 + toolbar.appendChild(editBtn); 120 + 121 + const modeBtn = document.createElement('button'); 122 + modeBtn.className = 'math-block-btn'; 123 + modeBtn.textContent = node.attrs.displayMode ? 'Display' : 'Inline'; 124 + modeBtn.title = 'Toggle display/inline mode'; 125 + toolbar.appendChild(modeBtn); 126 + 127 + dom.appendChild(toolbar); 128 + 129 + const codeEditor = document.createElement('textarea'); 130 + codeEditor.className = 'math-block-editor'; 131 + codeEditor.value = node.attrs.latex || DEFAULT_LATEX; 132 + codeEditor.style.display = 'none'; 133 + codeEditor.spellcheck = false; 134 + dom.appendChild(codeEditor); 135 + 136 + let editing = false; 137 + let currentLatex = node.attrs.latex || DEFAULT_LATEX; 138 + let displayMode = node.attrs.displayMode !== false; 139 + 140 + async function renderMath() { 141 + await ensureKaTeX(); 142 + const html = renderKaTeX(currentLatex, displayMode); 143 + output.innerHTML = html || `<span class="math-placeholder">Empty equation</span>`; 144 + } 145 + 146 + // Initial render 147 + renderMath(); 148 + 149 + // Show toolbar on hover 150 + dom.addEventListener('mouseenter', () => { toolbar.style.display = ''; }); 151 + dom.addEventListener('mouseleave', () => { if (!editing) toolbar.style.display = 'none'; }); 152 + 153 + // Edit button 154 + editBtn.addEventListener('click', (e) => { 155 + e.stopPropagation(); 156 + if (editing) { 157 + // Save and close 158 + editing = false; 159 + codeEditor.style.display = 'none'; 160 + editBtn.textContent = 'Edit'; 161 + const pos = typeof getPos === 'function' ? getPos() : null; 162 + if (pos !== null && pos !== undefined) { 163 + editor.chain().focus() 164 + .command(({ tr }) => { 165 + tr.setNodeMarkup(pos, undefined, { latex: currentLatex, displayMode }); 166 + return true; 167 + }).run(); 168 + } 169 + } else { 170 + editing = true; 171 + codeEditor.style.display = ''; 172 + codeEditor.value = currentLatex; 173 + codeEditor.focus(); 174 + editBtn.textContent = 'Done'; 175 + } 176 + }); 177 + 178 + // Double-click to edit 179 + dom.addEventListener('dblclick', () => { 180 + if (!editing) editBtn.click(); 181 + }); 182 + 183 + // Mode toggle 184 + modeBtn.addEventListener('click', (e) => { 185 + e.stopPropagation(); 186 + displayMode = !displayMode; 187 + modeBtn.textContent = displayMode ? 'Display' : 'Inline'; 188 + renderMath(); 189 + const pos = typeof getPos === 'function' ? getPos() : null; 190 + if (pos !== null && pos !== undefined) { 191 + editor.chain().focus() 192 + .command(({ tr }) => { 193 + tr.setNodeMarkup(pos, undefined, { latex: currentLatex, displayMode }); 194 + return true; 195 + }).run(); 196 + } 197 + }); 198 + 199 + // Live preview while editing 200 + let debounceTimer: ReturnType<typeof setTimeout> | null = null; 201 + codeEditor.addEventListener('input', () => { 202 + currentLatex = codeEditor.value; 203 + if (debounceTimer) clearTimeout(debounceTimer); 204 + debounceTimer = setTimeout(() => renderMath(), 200); 205 + }); 206 + 207 + // Escape closes editor 208 + codeEditor.addEventListener('keydown', (e) => { 209 + if (e.key === 'Escape') { editBtn.click(); e.preventDefault(); } 210 + e.stopPropagation(); 211 + }); 212 + 213 + return { 214 + dom, 215 + update(updatedNode) { 216 + if (updatedNode.type.name !== 'mathBlock') return false; 217 + currentLatex = updatedNode.attrs.latex || DEFAULT_LATEX; 218 + displayMode = updatedNode.attrs.displayMode !== false; 219 + if (!editing) codeEditor.value = currentLatex; 220 + modeBtn.textContent = displayMode ? 'Display' : 'Inline'; 221 + renderMath(); 222 + return true; 223 + }, 224 + stopEvent(event: Event) { 225 + return editing && event.target === codeEditor; 226 + }, 227 + destroy() { 228 + if (debounceTimer) clearTimeout(debounceTimer); 229 + }, 230 + }; 231 + }; 232 + }, 233 + }); 234 + 235 + function escapeHtml(s: string): string { 236 + return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 237 + }
+3
src/docs/extensions/slash-commands.ts
··· 115 115 mermaid: (editor: Editor) => { 116 116 editor.chain().focus().insertMermaidDiagram().run(); 117 117 }, 118 + mathBlock: (editor: Editor) => { 119 + editor.chain().focus().insertMathBlock().run(); 120 + }, 118 121 codeBlock: (editor: Editor) => { 119 122 editor.chain().focus().toggleCodeBlock().run(); 120 123 },
+2
src/docs/main.ts
··· 60 60 import { filterCommands, PLACEHOLDER_EMPTY, PLACEHOLDER_BLOCK } from './slash-menu.js'; 61 61 import { createSlashCommands, getCommandExecutor } from './extensions/slash-commands.js'; 62 62 import { MermaidBlock } from './extensions/mermaid-block.js'; 63 + import { MathBlock } from './extensions/math-block.js'; 63 64 import { createCommandPalette, type PaletteAction } from '../command-palette.js'; 64 65 65 66 // --- Extracted modules (phase 1) --- ··· 219 220 MarkdownAutoformat, 220 221 WikiLink, 221 222 MermaidBlock, 223 + MathBlock, 222 224 createSlashCommands({ 223 225 items: (query) => { 224 226 return filterCommands(query).map(item => ({
+8
src/docs/slash-menu.ts
··· 130 130 shortcut: null, 131 131 }, 132 132 { 133 + id: 'mathBlock', 134 + name: 'Math Equation', 135 + description: 'LaTeX math equation (KaTeX)', 136 + category: 'code', 137 + icon: '\u03A3', 138 + shortcut: null, 139 + }, 140 + { 133 141 id: 'codeBlock', 134 142 name: 'Code Block', 135 143 description: 'Fenced code block with syntax highlighting',