@editora/math 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,670 @@
1
+ import type { Plugin } from '@editora/core';
2
+
3
+ /**
4
+ * MathPlugin - Native implementation for mathematical equations
5
+ *
6
+ * Features:
7
+ * - Math dialog with LaTeX/MathML templates
8
+ * - Live KaTeX preview
9
+ * - Double-click to edit formulas
10
+ * - Inline/block math support
11
+ */
12
+
13
+ const MATH_TEMPLATES = {
14
+ latex: [
15
+ { name: 'Fraction', formula: '\\frac{a}{b}', description: 'Simple fraction' },
16
+ { name: 'Square Root', formula: '\\sqrt{x}', description: 'Square root' },
17
+ { name: 'Power', formula: 'x^{2}', description: 'Exponent/power' },
18
+ { name: 'Subscript', formula: 'x_{sub}', description: 'Subscript' },
19
+ { name: 'Integral', formula: '\\int_{a}^{b} f(x) \\, dx', description: 'Definite integral' },
20
+ { name: 'Summation', formula: '\\sum_{i=1}^{n} x_{i}', description: 'Summation' },
21
+ { name: 'Limit', formula: '\\lim_{x \\to 0} f(x)', description: 'Limit' },
22
+ { name: 'Derivative', formula: '\\frac{d}{dx} f(x)', description: 'Derivative' },
23
+ { name: 'Matrix 2x2', formula: '\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}', description: '2x2 matrix' },
24
+ { name: 'System', formula: '\\begin{cases} x + y = 1 \\\\ 2x - y = 0 \\end{cases}', description: 'System of equations' },
25
+ ],
26
+ mathml: [
27
+ { name: 'Fraction', formula: '<mfrac><mi>a</mi><mi>b</mi></mfrac>', description: 'Simple fraction' },
28
+ { name: 'Square Root', formula: '<msqrt><mi>x</mi></msqrt>', description: 'Square root' },
29
+ { name: 'Power', formula: '<msup><mi>x</mi><mn>2</mn></msup>', description: 'Exponent/power' },
30
+ { name: 'Subscript', formula: '<msub><mi>x</mi><mi>sub</mi></msub>', description: 'Subscript' },
31
+ { name: 'Parentheses', formula: '<mfenced open="(" close=")"><mi>a</mi><mo>+</mo><mi>b</mi></mfenced>', description: 'Grouped expression' },
32
+ ],
33
+ };
34
+
35
+ let savedSelection: Range | null = null;
36
+ let editingMathElement: HTMLElement | null = null;
37
+ let katexLoaded = false;
38
+ let activeMathEditorContent: HTMLElement | null = null;
39
+ let selectedMathElement: HTMLElement | null = null;
40
+ const DARK_THEME_SELECTOR = '[data-theme="dark"], .dark, .editora-theme-dark';
41
+
42
+ // Global flag to ensure listener is added only once across all instances
43
+ declare global {
44
+ interface Window {
45
+ __mathPluginDoubleClickInitialized?: boolean;
46
+ __mathPluginObjectSelectionInitialized?: boolean;
47
+ }
48
+ }
49
+
50
+ const loadKaTeX = (): Promise<any> => {
51
+ return new Promise((resolve, reject) => {
52
+ if ((window as any).katex) {
53
+ resolve((window as any).katex);
54
+ return;
55
+ }
56
+
57
+ if (katexLoaded) {
58
+ const checkInterval = setInterval(() => {
59
+ if ((window as any).katex) {
60
+ clearInterval(checkInterval);
61
+ resolve((window as any).katex);
62
+ }
63
+ }, 100);
64
+ return;
65
+ }
66
+
67
+ katexLoaded = true;
68
+
69
+ const link = document.createElement('link');
70
+ link.rel = 'stylesheet';
71
+ link.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css';
72
+ document.head.appendChild(link);
73
+
74
+ const script = document.createElement('script');
75
+ script.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js';
76
+ script.onload = () => resolve((window as any).katex);
77
+ script.onerror = reject;
78
+ document.head.appendChild(script);
79
+ });
80
+ };
81
+
82
+ const getEditorContentFromSelection = (): HTMLElement | null => {
83
+ const selection = window.getSelection();
84
+ if (!selection || selection.rangeCount === 0) return null;
85
+ const node = selection.getRangeAt(0).startContainer;
86
+ const element = node.nodeType === Node.ELEMENT_NODE
87
+ ? (node as HTMLElement)
88
+ : node.parentElement;
89
+ return (element?.closest('.rte-content, .editora-content') as HTMLElement | null) || null;
90
+ };
91
+
92
+ const getElementForNode = (node: Node | null): HTMLElement | null => {
93
+ if (!node) return null;
94
+ return node.nodeType === Node.ELEMENT_NODE ? node as HTMLElement : node.parentElement;
95
+ };
96
+
97
+ const getEditorContentForNode = (node: Node | null): HTMLElement | null => {
98
+ const element = getElementForNode(node);
99
+ return (element?.closest('.rte-content, .editora-content, [contenteditable="true"]') as HTMLElement | null) || null;
100
+ };
101
+
102
+ const recordDomHistoryTransaction = (editor: HTMLElement, beforeHTML: string, afterHTML: string): void => {
103
+ if (beforeHTML === afterHTML) return;
104
+ if (typeof (window as any).execEditorCommand === 'function') {
105
+ (window as any).execEditorCommand('recordDomTransaction', editor, beforeHTML, afterHTML);
106
+ return;
107
+ }
108
+
109
+ if (typeof (window as any).executeEditorCommand !== 'function') return;
110
+
111
+ try {
112
+ (window as any).executeEditorCommand('recordDomTransaction', { editor, beforeHTML, afterHTML });
113
+ } catch {
114
+ // History plugin may be unavailable.
115
+ }
116
+ };
117
+
118
+ const getMathElementFromTarget = (target: EventTarget | null): HTMLElement | null => {
119
+ if (!(target instanceof Element)) return null;
120
+ const mathElement = target.closest('.math-formula, .math-block');
121
+ return mathElement instanceof HTMLElement ? mathElement : null;
122
+ };
123
+
124
+ const clearSelectedMathElement = (): void => {
125
+ if (!selectedMathElement) return;
126
+ selectedMathElement.classList.remove('math-object-selected');
127
+ selectedMathElement.removeAttribute('data-rte-selected-object');
128
+ selectedMathElement = null;
129
+ };
130
+
131
+ const selectMathElement = (mathElement: HTMLElement): void => {
132
+ if (selectedMathElement === mathElement) return;
133
+
134
+ clearSelectedMathElement();
135
+ selectedMathElement = mathElement;
136
+ mathElement.classList.add('math-object-selected');
137
+ mathElement.setAttribute('data-rte-selected-object', 'true');
138
+
139
+ const selection = window.getSelection();
140
+ if (!selection) return;
141
+
142
+ const range = document.createRange();
143
+ range.selectNode(mathElement);
144
+ selection.removeAllRanges();
145
+ selection.addRange(range);
146
+ };
147
+
148
+ const placeCaretAfterMathDeletion = (editorContent: HTMLElement, referenceNode: ChildNode | null): void => {
149
+ const selection = window.getSelection();
150
+ if (!selection) return;
151
+
152
+ const range = document.createRange();
153
+ if (referenceNode && referenceNode.parentNode === editorContent) {
154
+ range.setStartBefore(referenceNode);
155
+ } else {
156
+ range.selectNodeContents(editorContent);
157
+ range.collapse(false);
158
+ }
159
+
160
+ range.collapse(true);
161
+ selection.removeAllRanges();
162
+ selection.addRange(range);
163
+ };
164
+
165
+ const ensureMathEditorHasBlock = (editorContent: HTMLElement): void => {
166
+ if (editorContent.childNodes.length > 0) return;
167
+ const paragraph = document.createElement('p');
168
+ paragraph.appendChild(document.createElement('br'));
169
+ editorContent.appendChild(paragraph);
170
+ };
171
+
172
+ const deleteSelectedMathElement = (): boolean => {
173
+ if (!selectedMathElement || !selectedMathElement.isConnected) {
174
+ clearSelectedMathElement();
175
+ return false;
176
+ }
177
+
178
+ const mathElement = selectedMathElement;
179
+ const editorContent = getEditorContentForNode(mathElement);
180
+ if (!editorContent) {
181
+ clearSelectedMathElement();
182
+ return false;
183
+ }
184
+
185
+ const beforeHTML = editorContent.innerHTML;
186
+ const nextSibling = mathElement.nextSibling;
187
+ mathElement.remove();
188
+ ensureMathEditorHasBlock(editorContent);
189
+ clearSelectedMathElement();
190
+ placeCaretAfterMathDeletion(editorContent, nextSibling);
191
+
192
+ const afterHTML = editorContent.innerHTML;
193
+ recordDomHistoryTransaction(editorContent, beforeHTML, afterHTML);
194
+ editorContent.dispatchEvent(new window.Event('input', { bubbles: true }));
195
+ return beforeHTML !== afterHTML;
196
+ };
197
+
198
+ const injectMathObjectSelectionStyles = (): void => {
199
+ if (document.getElementById('math-object-selection-styles')) return;
200
+
201
+ const style = document.createElement('style');
202
+ style.id = 'math-object-selection-styles';
203
+ style.textContent = `
204
+ .math-object-selected {
205
+ outline: 2px solid #2563eb !important;
206
+ outline-offset: 2px;
207
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.18);
208
+ }
209
+ `;
210
+ document.head.appendChild(style);
211
+ };
212
+
213
+ const initializeMathObjectSelection = (): void => {
214
+ if (typeof window === 'undefined' || window.__mathPluginObjectSelectionInitialized) return;
215
+ window.__mathPluginObjectSelectionInitialized = true;
216
+ injectMathObjectSelectionStyles();
217
+
218
+ document.addEventListener('pointerdown', (event) => {
219
+ const mathElement = getMathElementFromTarget(event.target);
220
+ if (!mathElement) {
221
+ const target = event.target as Element | null;
222
+ if (!target?.closest?.('.math-dialog-overlay')) {
223
+ clearSelectedMathElement();
224
+ }
225
+ return;
226
+ }
227
+
228
+ event.preventDefault();
229
+ getEditorContentForNode(mathElement)?.focus();
230
+ selectMathElement(mathElement);
231
+ }, true);
232
+
233
+ document.addEventListener('keydown', (event) => {
234
+ if (!selectedMathElement) return;
235
+
236
+ if (event.key === 'Escape') {
237
+ clearSelectedMathElement();
238
+ return;
239
+ }
240
+
241
+ if (event.key !== 'Backspace' && event.key !== 'Delete') return;
242
+
243
+ event.preventDefault();
244
+ event.stopPropagation();
245
+ deleteSelectedMathElement();
246
+ }, true);
247
+ };
248
+
249
+ const isDarkThemeContext = (editorContent?: HTMLElement | null): boolean => {
250
+ const source = editorContent || getEditorContentFromSelection();
251
+ if (source?.closest(DARK_THEME_SELECTOR)) return true;
252
+
253
+ const selection = window.getSelection();
254
+ if (selection && selection.rangeCount > 0) {
255
+ const node = selection.getRangeAt(0).startContainer;
256
+ const element = node.nodeType === Node.ELEMENT_NODE
257
+ ? (node as HTMLElement)
258
+ : node.parentElement;
259
+ if (element?.closest(DARK_THEME_SELECTOR)) return true;
260
+ }
261
+
262
+ const active = document.activeElement as HTMLElement | null;
263
+ if (active?.closest(DARK_THEME_SELECTOR)) return true;
264
+
265
+ return document.body.matches(DARK_THEME_SELECTOR) || document.documentElement.matches(DARK_THEME_SELECTOR);
266
+ };
267
+
268
+ const showMathDialog = async (
269
+ initialData?: { formula: string; format: 'latex' | 'mathml'; inline: boolean },
270
+ preferredEditorContent?: HTMLElement | null,
271
+ ) => {
272
+ const editorContent =
273
+ preferredEditorContent ||
274
+ (editingMathElement?.closest('.rte-content, .editora-content') as HTMLElement | null) ||
275
+ getEditorContentFromSelection() ||
276
+ activeMathEditorContent;
277
+ activeMathEditorContent = editorContent || null;
278
+
279
+ const selection = window.getSelection();
280
+ if (selection && selection.rangeCount > 0) {
281
+ const range = selection.getRangeAt(0);
282
+ savedSelection = editorContent && editorContent.contains(range.commonAncestorContainer)
283
+ ? range.cloneRange()
284
+ : null;
285
+ }
286
+
287
+ await loadKaTeX();
288
+ const isDarkTheme = isDarkThemeContext(editorContent);
289
+ const palette = isDarkTheme
290
+ ? {
291
+ overlay: 'rgba(0, 0, 0, 0.62)',
292
+ dialogBg: '#1f2937',
293
+ border: '#3b4657',
294
+ panelBg: '#222d3a',
295
+ fieldBg: '#111827',
296
+ fieldBorder: '#4b5563',
297
+ text: '#e2e8f0',
298
+ muted: '#94a3b8',
299
+ templateBtnBg: '#273244',
300
+ templateBtnHover: '#334155',
301
+ templateBtnText: '#dbe7f7',
302
+ templateSubText: '#9fb0c6',
303
+ previewBg: '#111827',
304
+ previewText: '#cbd5e1',
305
+ cancelBg: '#334155',
306
+ cancelText: '#e2e8f0',
307
+ cancelBorder: '#4b5563',
308
+ insertBg: '#3b82f6',
309
+ insertHover: '#2563eb',
310
+ invalid: '#f87171',
311
+ }
312
+ : {
313
+ overlay: 'rgba(0, 0, 0, 0.5)',
314
+ dialogBg: '#ffffff',
315
+ border: '#e1e5e9',
316
+ panelBg: '#f8f9fa',
317
+ fieldBg: '#ffffff',
318
+ fieldBorder: '#ced4da',
319
+ text: '#1f2937',
320
+ muted: '#6c757d',
321
+ templateBtnBg: '#ffffff',
322
+ templateBtnHover: '#f8f9fa',
323
+ templateBtnText: '#1f2937',
324
+ templateSubText: '#6c757d',
325
+ previewBg: '#f8f9fa',
326
+ previewText: '#6c757d',
327
+ cancelBg: '#ffffff',
328
+ cancelText: '#1f2937',
329
+ cancelBorder: '#ced4da',
330
+ insertBg: '#007bff',
331
+ insertHover: '#0069d9',
332
+ invalid: '#cc0000',
333
+ };
334
+
335
+ const overlay = document.createElement('div');
336
+ overlay.className = 'math-dialog-overlay';
337
+ overlay.style.cssText = `position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: ${palette.overlay}; display: flex; align-items: center; justify-content: center; z-index: 99999;`;
338
+
339
+ const dialog = document.createElement('div');
340
+ dialog.style.cssText = `background: ${palette.dialogBg}; border: 1px solid ${palette.border}; border-radius: 8px; width: 90%; max-width: 600px; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); color: ${palette.text};`;
341
+
342
+ let currentFormat: 'latex' | 'mathml' = initialData?.format || 'latex';
343
+ let currentFormula = initialData?.formula || '';
344
+ let currentInline = initialData?.inline !== false;
345
+ let previewRaf: number | null = null;
346
+ let lastPreviewSignature = '';
347
+
348
+ dialog.innerHTML = `
349
+ <div style="display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid ${palette.border}; background: ${palette.panelBg};">
350
+ <h2 style="margin: 0; font-size: 18px; font-weight: 600; color: ${palette.text};">${initialData ? 'Edit' : 'Insert'} Math Formula</h2>
351
+ <button class="close-btn" style="background: none; border: none; font-size: 28px; cursor: pointer; color: ${palette.muted}; padding: 0; width: 30px; height: 30px; line-height: 1;">×</button>
352
+ </div>
353
+
354
+ <div style="padding: 20px; overflow-y: auto; flex: 1;">
355
+ <div style="margin-bottom: 20px;">
356
+ <label style="display: block; font-weight: 600; margin-bottom: 8px; font-size: 14px; color: ${palette.text};">Format:</label>
357
+ <div style="display: flex; gap: 16px;">
358
+ <label style="cursor: pointer; color: ${palette.text};"><input type="radio" name="format" value="latex" ${currentFormat === 'latex' ? 'checked' : ''} style="margin-right: 6px;"> LaTeX</label>
359
+ <label style="cursor: pointer; color: ${palette.text};"><input type="radio" name="format" value="mathml" ${currentFormat === 'mathml' ? 'checked' : ''} style="margin-right: 6px;"> MathML</label>
360
+ </div>
361
+ </div>
362
+
363
+ <div style="margin-bottom: 20px;">
364
+ <label style="display: block; font-weight: 600; margin-bottom: 8px; font-size: 14px; color: ${palette.text};">Quick Templates:</label>
365
+ <div id="templates-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; max-height: 200px; overflow-y: auto;"></div>
366
+ </div>
367
+
368
+ <div style="margin-bottom: 20px;">
369
+ <label style="cursor: pointer; color: ${palette.text};"><input type="checkbox" id="inline-cb" ${currentInline ? 'checked' : ''} style="margin-right: 8px;"> Inline math</label>
370
+ </div>
371
+
372
+ <div style="margin-bottom: 20px;">
373
+ <label style="display: block; font-weight: 600; margin-bottom: 8px; font-size: 14px; color: ${palette.text};">Formula:</label>
374
+ <textarea id="formula-input" rows="4" style="width: 100%; min-height: 112px; padding: 10px 12px; border: 1px solid ${palette.fieldBorder}; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 14px; line-height: 1.45; background: ${palette.fieldBg}; color: ${palette.text}; box-sizing: border-box; overflow-x: hidden; overflow-y: auto; resize: vertical;">${currentFormula}</textarea>
375
+ </div>
376
+
377
+ <div style="margin-bottom: 20px;">
378
+ <label style="display: block; font-weight: 600; margin-bottom: 8px; font-size: 14px; color: ${palette.text};">Preview:</label>
379
+ <div id="preview-area" style="min-height: 60px; padding: 15px; border: 1px solid ${palette.fieldBorder}; border-radius: 4px; background: ${palette.previewBg}; display: flex; align-items: center; justify-content: center; color: ${palette.previewText};"></div>
380
+ </div>
381
+ </div>
382
+
383
+ <div style="display: flex; justify-content: flex-end; gap: 10px; padding: 16px 20px; border-top: 1px solid ${palette.border}; background: ${palette.panelBg};">
384
+ <button class="cancel-btn" style="padding: 10px 20px; background: ${palette.cancelBg}; color: ${palette.cancelText}; border: 1px solid ${palette.cancelBorder}; border-radius: 4px; cursor: pointer; font-size: 14px;">Cancel</button>
385
+ <button id="insert-btn" style="padding: 10px 20px; background: ${palette.insertBg}; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;" disabled>${initialData ? 'Update' : 'Insert'}</button>
386
+ </div>
387
+ `;
388
+
389
+ overlay.appendChild(dialog);
390
+ document.body.appendChild(overlay);
391
+
392
+ const formulaInput = dialog.querySelector('#formula-input') as HTMLTextAreaElement;
393
+ const previewArea = dialog.querySelector('#preview-area') as HTMLDivElement;
394
+ const templatesGrid = dialog.querySelector('#templates-grid') as HTMLDivElement;
395
+ const formatRadios = dialog.querySelectorAll('input[name="format"]') as NodeListOf<HTMLInputElement>;
396
+ const inlineCb = dialog.querySelector('#inline-cb') as HTMLInputElement;
397
+ const insertBtn = dialog.querySelector('#insert-btn') as HTMLButtonElement;
398
+ const closeBtn = dialog.querySelector('.close-btn') as HTMLButtonElement;
399
+ const cancelBtn = dialog.querySelector('.cancel-btn') as HTMLButtonElement;
400
+
401
+ const encodeFormulaAttr = (formula: string): string => encodeURIComponent(formula);
402
+ const decodeFormulaAttr = (encoded: string): string => {
403
+ try {
404
+ return decodeURIComponent(encoded);
405
+ } catch {
406
+ return encoded;
407
+ }
408
+ };
409
+
410
+ const updateTemplates = () => {
411
+ const templates = MATH_TEMPLATES[currentFormat];
412
+ templatesGrid.innerHTML = templates.map(t => `
413
+ <button type="button" data-formula="${encodeFormulaAttr(t.formula)}" title="${t.description}" style="padding: 8px; border: 1px solid ${palette.fieldBorder}; border-radius: 4px; background: ${palette.templateBtnBg}; cursor: pointer; text-align: left; transition: background-color 0.16s ease;">
414
+ <div style="font-weight: 600; font-size: 12px; color: ${palette.templateBtnText};">${t.name}</div>
415
+ <div style="font-size: 10px; color: ${palette.templateSubText}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${t.formula.substring(0, 20)}...</div>
416
+ </button>
417
+ `).join('');
418
+ };
419
+
420
+ const updatePreview = () => {
421
+ const formula = formulaInput.value.trim();
422
+ const signature = `${currentFormat}:${formula}`;
423
+ if (signature === lastPreviewSignature) return;
424
+ lastPreviewSignature = signature;
425
+
426
+ if (!formula) {
427
+ previewArea.innerHTML = `<span style="color: ${palette.previewText};">Enter a formula to see preview</span>`;
428
+ insertBtn.disabled = true;
429
+ return;
430
+ }
431
+
432
+ insertBtn.disabled = false;
433
+
434
+ try {
435
+ if (currentFormat === 'latex') {
436
+ const katex = (window as any).katex;
437
+ previewArea.innerHTML = katex.renderToString(formula, { displayMode: false, throwOnError: false });
438
+ } else {
439
+ // For MathML, check if it starts with <math>, if not, wrap it
440
+ if (formula.trim().startsWith('<math')) {
441
+ previewArea.innerHTML = formula;
442
+ } else {
443
+ // Wrap in <math> tag and render
444
+ previewArea.innerHTML = `<math xmlns="http://www.w3.org/1998/Math/MathML" display="inline">${formula}</math>`;
445
+ }
446
+ }
447
+ } catch {
448
+ previewArea.innerHTML = `<span style="color: ${palette.invalid};">Invalid formula</span>`;
449
+ }
450
+ };
451
+
452
+ const schedulePreview = () => {
453
+ if (previewRaf !== null) {
454
+ cancelAnimationFrame(previewRaf);
455
+ }
456
+ previewRaf = requestAnimationFrame(() => {
457
+ previewRaf = null;
458
+ updatePreview();
459
+ });
460
+ };
461
+
462
+ const handleEscape = (e: KeyboardEvent) => {
463
+ if (e.key !== 'Escape') return;
464
+ e.preventDefault();
465
+ e.stopPropagation();
466
+ closeDialog();
467
+ };
468
+
469
+ const closeDialog = () => {
470
+ document.removeEventListener('keydown', handleEscape, true);
471
+ if (previewRaf !== null) {
472
+ cancelAnimationFrame(previewRaf);
473
+ previewRaf = null;
474
+ }
475
+ if (overlay.parentNode) {
476
+ overlay.parentNode.removeChild(overlay);
477
+ }
478
+ };
479
+ closeBtn.onmouseover = () => {
480
+ closeBtn.style.color = '#f8fafc';
481
+ closeBtn.style.background = isDarkTheme ? '#334155' : '#e5e7eb';
482
+ closeBtn.style.borderRadius = '4px';
483
+ };
484
+ closeBtn.onmouseout = () => {
485
+ closeBtn.style.color = palette.muted;
486
+ closeBtn.style.background = 'none';
487
+ };
488
+ cancelBtn.onmouseover = () => {
489
+ cancelBtn.style.background = isDarkTheme ? '#475569' : '#f3f4f6';
490
+ };
491
+ cancelBtn.onmouseout = () => {
492
+ cancelBtn.style.background = palette.cancelBg;
493
+ };
494
+ insertBtn.onmouseover = () => {
495
+ if (!insertBtn.disabled) insertBtn.style.background = palette.insertHover;
496
+ };
497
+ insertBtn.onmouseout = () => {
498
+ insertBtn.style.background = palette.insertBg;
499
+ };
500
+
501
+ templatesGrid.addEventListener('mouseover', (event) => {
502
+ const target = event.target as HTMLElement;
503
+ const button = target.closest('button[data-formula]') as HTMLButtonElement | null;
504
+ if (!button) return;
505
+ button.style.background = palette.templateBtnHover;
506
+ });
507
+
508
+ templatesGrid.addEventListener('mouseout', (event) => {
509
+ const target = event.target as HTMLElement;
510
+ const button = target.closest('button[data-formula]') as HTMLButtonElement | null;
511
+ if (!button) return;
512
+ button.style.background = palette.templateBtnBg;
513
+ });
514
+
515
+ templatesGrid.addEventListener('click', (event) => {
516
+ const target = event.target as HTMLElement;
517
+ const button = target.closest('button[data-formula]') as HTMLButtonElement | null;
518
+ if (!button) return;
519
+ formulaInput.value = decodeFormulaAttr(button.getAttribute('data-formula') || '');
520
+ currentFormula = formulaInput.value;
521
+ schedulePreview();
522
+ });
523
+
524
+ const insertMath = () => {
525
+ const formula = formulaInput.value.trim();
526
+ if (!formula) return;
527
+
528
+ const mathData = { formula, format: currentFormat, inline: inlineCb.checked };
529
+ const mathEl = mathData.inline ? document.createElement('span') : document.createElement('div');
530
+
531
+ mathEl.className = mathData.inline ? 'math-formula' : 'math-block';
532
+ mathEl.setAttribute('data-math-formula', formula);
533
+ mathEl.setAttribute('data-math-format', currentFormat);
534
+ mathEl.contentEditable = 'false';
535
+ mathEl.style.cssText = mathData.inline
536
+ ? 'background: #f0f8ff; border: 1px solid #b8daff; border-radius: 4px; padding: 2px 6px; margin: 0 2px; color: #004085; display: inline-block; cursor: pointer;'
537
+ : 'background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 12px; margin: 8px 0; text-align: center; display: block; cursor: pointer;';
538
+
539
+ // Render with KaTeX for LaTeX formulas
540
+ if (currentFormat === 'latex') {
541
+ const katex = (window as any).katex;
542
+ try {
543
+ mathEl.innerHTML = katex.renderToString(formula, {
544
+ displayMode: !mathData.inline,
545
+ throwOnError: false
546
+ });
547
+ } catch {
548
+ mathEl.textContent = mathData.inline ? `$${formula}$` : `$$${formula}$$`;
549
+ }
550
+ } else {
551
+ // For MathML, wrap in <math> tag if not already wrapped
552
+ if (formula.trim().startsWith('<math')) {
553
+ mathEl.innerHTML = formula;
554
+ } else {
555
+ // Create proper MathML with namespace
556
+ const mathContent = `<math xmlns="http://www.w3.org/1998/Math/MathML" display="${mathData.inline ? 'inline' : 'block'}">${formula}</math>`;
557
+ mathEl.innerHTML = mathContent;
558
+ }
559
+ }
560
+
561
+ if (editingMathElement) {
562
+ editingMathElement.replaceWith(mathEl);
563
+ } else if (savedSelection) {
564
+ savedSelection.deleteContents();
565
+ savedSelection.insertNode(mathEl);
566
+ } else if (activeMathEditorContent && activeMathEditorContent.isConnected) {
567
+ const fallbackRange = document.createRange();
568
+ fallbackRange.selectNodeContents(activeMathEditorContent);
569
+ fallbackRange.collapse(false);
570
+ fallbackRange.insertNode(mathEl);
571
+ }
572
+
573
+ const targetEditor = (mathEl.closest('.rte-content, .editora-content') as HTMLElement | null) || activeMathEditorContent;
574
+ targetEditor?.dispatchEvent(new Event('input', { bubbles: true }));
575
+
576
+ closeDialog();
577
+ };
578
+
579
+ closeBtn.addEventListener('click', closeDialog);
580
+ cancelBtn.addEventListener('click', closeDialog);
581
+ insertBtn.addEventListener('click', insertMath);
582
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDialog(); });
583
+ document.addEventListener('keydown', handleEscape, true);
584
+
585
+ formatRadios.forEach(radio => {
586
+ radio.addEventListener('change', (e) => {
587
+ currentFormat = (e.target as HTMLInputElement).value as 'latex' | 'mathml';
588
+ lastPreviewSignature = '';
589
+ updateTemplates();
590
+ schedulePreview();
591
+ });
592
+ });
593
+
594
+ formulaInput.addEventListener('input', () => {
595
+ currentFormula = formulaInput.value;
596
+ schedulePreview();
597
+ });
598
+
599
+ formulaInput.addEventListener('keydown', (e) => {
600
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
601
+ e.preventDefault();
602
+ insertMath();
603
+ }
604
+ });
605
+
606
+ updateTemplates();
607
+ schedulePreview();
608
+ formulaInput.focus();
609
+ };
610
+
611
+ // Initialize double-click editing (only once globally)
612
+ if (typeof window !== 'undefined' && !window.__mathPluginDoubleClickInitialized) {
613
+ window.__mathPluginDoubleClickInitialized = true;
614
+
615
+ const handleMathDoubleClick = (e: MouseEvent) => {
616
+ const target = e.target as HTMLElement;
617
+ const mathEl = target.closest('.math-formula, .math-block') as HTMLElement;
618
+
619
+ if (mathEl) {
620
+ e.preventDefault();
621
+ e.stopPropagation();
622
+ e.stopImmediatePropagation();
623
+
624
+ clearSelectedMathElement();
625
+ editingMathElement = mathEl;
626
+ const formula = mathEl.getAttribute('data-math-formula') || '';
627
+ const format = (mathEl.getAttribute('data-math-format') || 'latex') as 'latex' | 'mathml';
628
+ const inline = mathEl.classList.contains('math-formula');
629
+ showMathDialog({ formula, format, inline });
630
+ }
631
+ };
632
+
633
+ const initDoubleClick = () => {
634
+ document.addEventListener('dblclick', handleMathDoubleClick, { capture: true });
635
+ };
636
+
637
+ if (document.readyState === 'loading') {
638
+ document.addEventListener('DOMContentLoaded', initDoubleClick);
639
+ } else {
640
+ setTimeout(initDoubleClick, 100);
641
+ }
642
+ }
643
+
644
+ export const MathPlugin = (): Plugin => ({
645
+ name: "math",
646
+ init: initializeMathObjectSelection,
647
+
648
+ toolbar: [
649
+ {
650
+ label: "Insert Math",
651
+ command: "insertMath",
652
+ icon: '<svg width="24" height="24" focusable="false"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 4.8c.1-.5.5-.8 1-.8h10a1 1 0 1 1 0 2h-9.2L8.3 19.2a1 1 0 0 1-1.7.4l-3.4-4.2a1 1 0 0 1 1.6-1.2l2 2.5L9 4.8Zm9.7 5.5c.4.4.4 1 0 1.4L17 13.5l1.8 1.8a1 1 0 0 1-1.4 1.4L15.5 15l-1.8 1.8a1 1 0 0 1-1.4-1.4l1.8-1.8-1.8-1.8a1 1 0 0 1 1.4-1.4l1.8 1.8 1.8-1.8a1 1 0 0 1 1.4 0Z"></path></svg>',
653
+ },
654
+ ],
655
+
656
+ commands: {
657
+ insertMath: (_args: unknown, context: any) => {
658
+ const editorContent =
659
+ context?.contentElement instanceof HTMLElement
660
+ ? context.contentElement
661
+ : null;
662
+ showMathDialog(undefined, editorContent);
663
+ return true;
664
+ },
665
+ },
666
+
667
+ keymap: {
668
+ "Mod-Shift-m": "insertMath",
669
+ },
670
+ });