Full document, spreadsheet, slideshow, and diagram tooling
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, '&').replace(/</g, '<').replace(/>/g, '>');
239}