@editora/math 1.0.1 → 1.0.2

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.
package/src/MathPlugin.ts DELETED
@@ -1,1099 +0,0 @@
1
- import { Plugin } from '@editora/core';
2
- // import { MathProvider } from './MathProvider';
3
- import katex from 'katex';
4
- import {
5
- convertMathMLToLatexManual
6
- } from './libs/mathml-converter';
7
-
8
- // MathJax types (simplified)
9
- declare global {
10
- interface Window {
11
- MathJax?: any;
12
- }
13
- }
14
-
15
- /**
16
- * Math Plugin for Rich Text Editor
17
- *
18
- * Allows users to insert and edit mathematical formulas
19
- * using LaTeX or MathML syntax with live preview
20
- */
21
-
22
- // Math Node Model - Source of Truth
23
- export interface MathNode {
24
- id: string;
25
- type: 'inline' | 'block';
26
- format: 'latex' | 'mathml';
27
- formula: string;
28
- renderedHtml: string;
29
- semanticLabel: string;
30
- created: Date;
31
- modified: Date;
32
- }
33
-
34
- // Math Registry - Central store for all math nodes
35
- class MathRegistry {
36
- private nodes = new Map<string, MathNode>();
37
- private observers: ((node: MathNode, action: 'add' | 'update' | 'delete') => void)[] = [];
38
-
39
- add(node: MathNode): void {
40
- this.nodes.set(node.id, node);
41
- this.notifyObservers(node, 'add');
42
- }
43
-
44
- update(id: string, updates: Partial<MathNode>): void {
45
- const node = this.nodes.get(id);
46
- if (node) {
47
- Object.assign(node, updates, { modified: new Date() });
48
- this.notifyObservers(node, 'update');
49
- }
50
- }
51
-
52
- delete(id: string): void {
53
- const node = this.nodes.get(id);
54
- if (node) {
55
- this.nodes.delete(id);
56
- this.notifyObservers(node, 'delete');
57
- }
58
- }
59
-
60
- get(id: string): MathNode | undefined {
61
- return this.nodes.get(id);
62
- }
63
-
64
- getAll(): MathNode[] {
65
- return Array.from(this.nodes.values());
66
- }
67
-
68
- subscribe(observer: (node: MathNode, action: 'add' | 'update' | 'delete') => void): () => void {
69
- this.observers.push(observer);
70
- return () => {
71
- const index = this.observers.indexOf(observer);
72
- if (index > -1) {
73
- this.observers.splice(index, 1);
74
- }
75
- };
76
- }
77
-
78
- private notifyObservers(node: MathNode, action: 'add' | 'update' | 'delete'): void {
79
- this.observers.forEach(observer => observer(node, action));
80
- }
81
- }
82
-
83
- // Selection Manager - Handles cursor and selection behavior
84
- class MathSelectionManager {
85
- private registry: MathRegistry;
86
-
87
- constructor(registry: MathRegistry) {
88
- this.registry = registry;
89
- this.initializeEventListeners();
90
- }
91
-
92
- private initializeEventListeners(): void {
93
- document.addEventListener('keydown', this.handleKeydown.bind(this));
94
- document.addEventListener('click', this.handleClick.bind(this));
95
- }
96
-
97
- private handleKeydown(event: KeyboardEvent): void {
98
- const selection = window.getSelection();
99
- if (!selection || selection.rangeCount === 0) return;
100
-
101
- const range = selection.getRangeAt(0);
102
-
103
- // Check if selection is inside a math element
104
- const mathElement = this.findMathElement(range.commonAncestorContainer);
105
- if (mathElement) {
106
- this.handleSelectionInMath(event, range, mathElement);
107
- } else {
108
- this.handleSelectionNearMath(event, range);
109
- }
110
- }
111
-
112
- private handleClick(event: MouseEvent): void {
113
- const target = event.target as HTMLElement;
114
- const mathElement = target.closest('.math-formula') as HTMLElement;
115
-
116
- if (mathElement) {
117
- event.preventDefault();
118
- event.stopPropagation();
119
-
120
- // Select the entire math span
121
- const selection = window.getSelection();
122
- const range = document.createRange();
123
- range.selectNodeContents(mathElement);
124
- selection?.removeAllRanges();
125
- selection?.addRange(range);
126
- }
127
- }
128
-
129
-
130
- private handleSelectionInMath(event: KeyboardEvent, range: Range, mathElement: HTMLElement): void {
131
- // Prevent normal text editing inside math elements
132
- if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
133
- event.preventDefault();
134
- // Move cursor after the math element
135
- this.placeCursorAfterMath(mathElement);
136
- }
137
-
138
- // Handle deletion of math elements
139
- if (event.key === 'Delete' || event.key === 'Backspace') {
140
- event.preventDefault();
141
- this.deleteMathElement(mathElement);
142
- }
143
- }
144
-
145
- private handleSelectionNearMath(event: KeyboardEvent, range: Range): void {
146
- // Handle deletion near math elements
147
- if (event.key === 'Backspace') {
148
- const previousElement = this.findPreviousElement(range.startContainer, range.startOffset);
149
- if (previousElement && previousElement.classList.contains('math-formula')) {
150
- event.preventDefault();
151
- this.deleteMathElement(previousElement);
152
- }
153
- }
154
-
155
- if (event.key === 'Delete') {
156
- const nextElement = this.findNextElement(range.endContainer, range.endOffset);
157
- if (nextElement && nextElement.classList.contains('math-formula')) {
158
- event.preventDefault();
159
- this.deleteMathElement(nextElement);
160
- }
161
- }
162
- }
163
-
164
- private findMathElement(node: Node): HTMLElement | null {
165
- if (node.nodeType === Node.ELEMENT_NODE) {
166
- const element = node as HTMLElement;
167
- if (element.classList.contains('math-formula')) {
168
- return element;
169
- }
170
- }
171
-
172
- let current = node;
173
- while (current) {
174
- if (current.nodeType === Node.ELEMENT_NODE) {
175
- const element = current as HTMLElement;
176
- if (element.classList.contains('math-formula')) {
177
- return element;
178
- }
179
- }
180
- current = current.parentNode as Node;
181
- }
182
-
183
- return null;
184
- }
185
-
186
- private placeCursorAfterMath(mathElement: HTMLElement): void {
187
- const selection = window.getSelection()!;
188
- const range = document.createRange();
189
-
190
- const nextNode = this.findNextTextNode(mathElement);
191
- if (nextNode) {
192
- range.setStart(nextNode, 0);
193
- range.setEnd(nextNode, 0);
194
- } else {
195
- // Insert a text node if none exists
196
- const textNode = document.createTextNode('');
197
- if (mathElement.parentNode) {
198
- mathElement.parentNode.insertBefore(textNode, mathElement.nextSibling);
199
- range.setStart(textNode, 0);
200
- range.setEnd(textNode, 0);
201
- }
202
- }
203
-
204
- selection.removeAllRanges();
205
- selection.addRange(range);
206
- }
207
-
208
- private deleteMathElement(mathElement: HTMLElement): void {
209
- const mathId = mathElement.getAttribute('data-math-id');
210
- if (mathId) {
211
- this.registry.delete(mathId);
212
- }
213
- mathElement.remove();
214
- }
215
-
216
- private findPreviousElement(container: Node, offset: number): HTMLElement | null {
217
- if (container.nodeType === Node.TEXT_NODE) {
218
- if (offset > 0) return null;
219
- const parent = container.parentNode as HTMLElement;
220
- const siblings = Array.from(parent.parentNode?.children || []);
221
- const index = siblings.indexOf(parent);
222
- for (let i = index - 1; i >= 0; i--) {
223
- const sibling = siblings[i] as HTMLElement;
224
- if (sibling.classList.contains('math-formula')) {
225
- return sibling;
226
- }
227
- }
228
- }
229
- return null;
230
- }
231
-
232
- private findNextElement(container: Node, offset: number): HTMLElement | null {
233
- if (container.nodeType === Node.TEXT_NODE) {
234
- const textNode = container as Text;
235
- if (offset < textNode.length) return null;
236
- const parent = container.parentNode as HTMLElement;
237
- const siblings = Array.from(parent.parentNode?.children || []);
238
- const index = siblings.indexOf(parent);
239
- for (let i = index + 1; i < siblings.length; i++) {
240
- const sibling = siblings[i] as HTMLElement;
241
- if (sibling.classList.contains('math-formula')) {
242
- return sibling;
243
- }
244
- }
245
- }
246
- return null;
247
- }
248
-
249
- private findNextTextNode(element: HTMLElement): Text | null {
250
- let current: Node | null = element.nextSibling;
251
- while (current) {
252
- if (current.nodeType === Node.TEXT_NODE && current.textContent?.trim()) {
253
- return current as Text;
254
- }
255
- if (current.nodeType === Node.ELEMENT_NODE && !(current as HTMLElement).classList.contains('math-formula')) {
256
- // Look inside non-math elements for text nodes
257
- const textNode = this.findTextNodeInElement(current as HTMLElement);
258
- if (textNode) return textNode;
259
- }
260
- current = current.nextSibling;
261
- }
262
- return null;
263
- }
264
-
265
- private findTextNodeInElement(element: HTMLElement): Text | null {
266
- for (const child of element.childNodes) {
267
- if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) {
268
- return child as Text;
269
- }
270
- if (child.nodeType === Node.ELEMENT_NODE) {
271
- const textNode = this.findTextNodeInElement(child as HTMLElement);
272
- if (textNode) return textNode;
273
- }
274
- }
275
- return null;
276
- }
277
- }
278
-
279
- // Math Renderer - Solution A: KaTeX + Browser MathML (Production Ready)
280
- class MathRenderer {
281
- private cache = new Map<string, { html: string; semantic: string }>();
282
- private registry: MathRegistry;
283
-
284
- constructor(registry: MathRegistry) {
285
- this.registry = registry;
286
- }
287
-
288
- render(node: MathNode): { html: string; semantic: string } {
289
- const cacheKey = `${node.format}:${node.formula}:${node.type}`;
290
-
291
- if (this.cache.has(cacheKey)) {
292
- return this.cache.get(cacheKey)!;
293
- }
294
-
295
- const result = this.doRender(node);
296
- this.cache.set(cacheKey, result);
297
- return result;
298
- }
299
-
300
- private doRender(node: MathNode): { html: string; semantic: string } {
301
- try {
302
- // Generate semantic label
303
- const semanticLabel = this.generateSemanticLabel(node.formula, node.format);
304
-
305
- if (node.format === 'latex') {
306
- // SOLUTION A: Render LaTeX with KaTeX (fast and reliable)
307
- try {
308
- const renderedHtml = katex.renderToString(node.formula, {
309
- displayMode: node.type === 'block',
310
- throwOnError: false,
311
- errorColor: '#cc0000'
312
- }).replace('aria-hidden="true"', ''); // Remove aria-hidden for accessibility
313
-
314
- console.log(`✅ LaTeX rendered with KaTeX: ${node.formula.substring(0, 30)}...`);
315
- return { html: renderedHtml, semantic: semanticLabel };
316
- } catch (katexError) {
317
- console.error('❌ KaTeX rendering failed:', katexError);
318
- const fallback = `<span class="math-error">[LaTeX Error: ${node.formula.substring(0, 20)}]</span>`;
319
- return { html: fallback, semantic: semanticLabel };
320
- }
321
-
322
- } else if (node.format === 'mathml') {
323
- // SOLUTION A: Store MathML as-is and render appropriately
324
-
325
- // First, try browser's native MathML support (works in modern browsers)
326
- try {
327
- // Check if browser supports MathML
328
- const testElement = document.createElement('math');
329
- if (testElement.toString() !== '[object HTMLUnknownElement]') {
330
- // Browser has MathML support - render natively
331
- const mathContainer = document.createElement('span');
332
- mathContainer.className = 'mathml-native';
333
- mathContainer.innerHTML = node.formula;
334
-
335
- console.log(`✅ MathML rendered natively: ${node.formula.substring(0, 30)}...`);
336
- return { html: mathContainer.outerHTML, semantic: semanticLabel };
337
- }
338
- } catch (nativeError) {
339
- console.warn('⚠️ Native MathML not supported, using fallback');
340
- }
341
-
342
- // Fallback: Convert MathML to LaTeX and render with KaTeX
343
- // This preserves the MathML input but renders it using KaTeX
344
- try {
345
- const latexEquivalent = this.convertMathMLToLatex(node.formula);
346
- const renderedHtml = katex.renderToString(latexEquivalent, {
347
- displayMode: node.type === 'block',
348
- throwOnError: false,
349
- errorColor: '#cc0000'
350
- }).replace('aria-hidden="true"', '');
351
-
352
- console.log(`✅ MathML converted to LaTeX and rendered: ${node.formula.substring(0, 30)}...`);
353
- return { html: renderedHtml, semantic: semanticLabel };
354
- } catch (conversionError) {
355
- console.error('❌ MathML conversion failed:', conversionError);
356
- }
357
-
358
- // Final fallback: Show MathML as formatted code
359
- const codeElement = `<code class="mathml-raw" title="MathML">${this.escapeHtml(node.formula)}</code>`;
360
- console.log(`⚠️ MathML fallback to raw display: ${node.formula.substring(0, 30)}...`);
361
- return { html: codeElement, semantic: semanticLabel };
362
- }
363
-
364
- // Unknown format fallback
365
- const fallback = `<span class="math-unknown">[${String(node.format).toUpperCase()}: ${node.formula.substring(0, 20)}]</span>`;
366
- return { html: fallback, semantic: node.formula };
367
-
368
- } catch (error) {
369
- console.error('❌ Math rendering failed:', error);
370
- const fallback = `<span class="math-error">[Error: ${node.formula.substring(0, 20)}]</span>`;
371
- return { html: fallback, semantic: node.formula };
372
- }
373
- }
374
-
375
- // Use the library function for MathML conversion
376
- private convertMathMLToLatex(mathml: string): string {
377
- return convertMathMLToLatexManual(mathml);
378
- }
379
-
380
- private generateSemanticLabel(formula: string, format: 'latex' | 'mathml'): string {
381
- if (format === 'latex') {
382
- // Basic LaTeX to English conversion for accessibility
383
- return formula
384
- .replace(/\\frac{([^}]*)}{([^}]*)}/g, 'fraction $1 over $2')
385
- .replace(/\\sqrt{([^}]*)}/g, 'square root of $1')
386
- .replace(/([^_]*)_{([^}]*)}/g, '$1 sub $2')
387
- .replace(/([^\\^]*)\\^([^}]*)/g, '$1 to the power of $2')
388
- .replace(/[{}]/g, '')
389
- .replace(/\\/g, '');
390
- } else {
391
- // For MathML, extract meaningful text content
392
- const tempDiv = document.createElement('div');
393
- tempDiv.innerHTML = formula;
394
- return tempDiv.textContent || formula;
395
- }
396
- }
397
-
398
- private escapeHtml(text: string): string {
399
- const div = document.createElement('div');
400
- div.textContent = text;
401
- return div.innerHTML;
402
- }
403
- }
404
-
405
- // Undo/Redo Manager - Phase 3 Implementation
406
- class MathUndoManager {
407
- private operations: MathOperation[] = [];
408
- private maxOperations = 50; // Limit undo history
409
-
410
- record(operation: MathOperation): void {
411
- this.operations.push(operation);
412
- // Limit history size
413
- if (this.operations.length > this.maxOperations) {
414
- this.operations.shift();
415
- }
416
- }
417
-
418
- canUndo(): boolean {
419
- return this.operations.length > 0;
420
- }
421
-
422
- canRedo(): boolean {
423
- return false; // For now, no redo support
424
- }
425
-
426
- undo(): void {
427
- const operation = this.operations.pop();
428
- if (operation) {
429
- operation.undo();
430
- }
431
- }
432
-
433
- clear(): void {
434
- this.operations = [];
435
- }
436
- }
437
-
438
- interface MathOperation {
439
- type: 'insert' | 'update' | 'delete';
440
- nodeId: string;
441
- before: MathNode | null;
442
- after: MathNode | null;
443
- timestamp: Date;
444
-
445
- undo(): void;
446
- redo(): void;
447
- }
448
-
449
- // Export/Import Manager - Phase 3 Implementation
450
- class MathSerializationManager {
451
- private registry: MathRegistry;
452
-
453
- constructor(registry: MathRegistry) {
454
- this.registry = registry;
455
- }
456
-
457
- // Export math content as JSON
458
- exportToJSON(): string {
459
- const nodes = this.registry.getAll();
460
- const exportData = {
461
- version: '1.0',
462
- timestamp: new Date().toISOString(),
463
- mathNodes: nodes.map(node => ({
464
- id: node.id,
465
- type: node.type,
466
- format: node.format,
467
- formula: node.formula,
468
- created: node.created.toISOString(),
469
- modified: node.modified.toISOString()
470
- }))
471
- };
472
- return JSON.stringify(exportData, null, 2);
473
- }
474
-
475
- // Import math content from JSON
476
- importFromJSON(jsonString: string): { success: boolean; imported: number; errors: string[] } {
477
- const errors: string[] = [];
478
- let imported = 0;
479
-
480
- try {
481
- const importData = JSON.parse(jsonString);
482
-
483
- if (!importData.mathNodes || !Array.isArray(importData.mathNodes)) {
484
- errors.push('Invalid JSON format: missing mathNodes array');
485
- return { success: false, imported: 0, errors };
486
- }
487
-
488
- for (const nodeData of importData.mathNodes) {
489
- try {
490
- const node: MathNode = {
491
- id: nodeData.id || generateMathId(),
492
- type: nodeData.type || 'inline',
493
- format: nodeData.format || 'latex',
494
- formula: nodeData.formula || '',
495
- renderedHtml: '',
496
- semanticLabel: '',
497
- created: nodeData.created ? new Date(nodeData.created) : new Date(),
498
- modified: nodeData.modified ? new Date(nodeData.modified) : new Date()
499
- };
500
-
501
- // Validate the node
502
- if (!node.formula.trim()) {
503
- errors.push(`Skipping empty formula for node ${node.id}`);
504
- continue;
505
- }
506
-
507
- // Render the node
508
- const renderResult = mathRenderer.render(node);
509
- node.renderedHtml = renderResult.html;
510
- node.semanticLabel = renderResult.semantic;
511
-
512
- this.registry.add(node);
513
- imported++;
514
-
515
- } catch (nodeError) {
516
- errors.push(`Failed to import node ${nodeData.id}: ${nodeError}`);
517
- }
518
- }
519
-
520
- } catch (parseError) {
521
- errors.push(`JSON parsing failed: ${parseError}`);
522
- return { success: false, imported: 0, errors };
523
- }
524
-
525
- return {
526
- success: errors.length === 0,
527
- imported,
528
- errors
529
- };
530
- }
531
-
532
- // Export as HTML with data attributes
533
- exportToHTML(): string {
534
- const nodes = this.registry.getAll();
535
- let html = '';
536
-
537
- for (const node of nodes) {
538
- const element = createMathElement(node);
539
- html += element.outerHTML + '\n';
540
- }
541
-
542
- return html;
543
- }
544
-
545
- // Import from HTML with data attributes
546
- importFromHTML(htmlString: string): { success: boolean; imported: number; errors: string[] } {
547
- const errors: string[] = [];
548
- let imported = 0;
549
-
550
- try {
551
- const tempDiv = document.createElement('div');
552
- tempDiv.innerHTML = htmlString;
553
-
554
- const mathElements = tempDiv.querySelectorAll('.math-formula');
555
- for (const element of mathElements) {
556
- try {
557
- const node: MathNode = {
558
- id: element.getAttribute('data-math-id') || generateMathId(),
559
- type: (element.getAttribute('data-math-type') as 'inline' | 'block') || 'inline',
560
- format: (element.getAttribute('data-math-format') as 'latex' | 'mathml') || 'latex',
561
- formula: element.getAttribute('data-math-formula') || '',
562
- renderedHtml: element.innerHTML,
563
- semanticLabel: element.getAttribute('aria-label') || '',
564
- created: new Date(),
565
- modified: new Date()
566
- };
567
-
568
- // Validate the node
569
- if (!node.formula.trim()) {
570
- errors.push(`Skipping empty formula for node ${node.id}`);
571
- continue;
572
- }
573
-
574
- this.registry.add(node);
575
- imported++;
576
-
577
- } catch (nodeError) {
578
- errors.push(`Failed to import HTML node: ${nodeError}`);
579
- }
580
- }
581
-
582
- } catch (htmlError) {
583
- errors.push(`HTML parsing failed: ${htmlError}`);
584
- return { success: false, imported: 0, errors };
585
- }
586
-
587
- return {
588
- success: errors.length === 0,
589
- imported,
590
- errors
591
- };
592
- }
593
- }
594
-
595
- // Global instances (isolated within plugin)
596
- const mathRegistry = new MathRegistry();
597
- const mathRenderer = new MathRenderer(mathRegistry);
598
- const mathSelectionManager = new MathSelectionManager(mathRegistry);
599
- const mathUndoManager = new MathUndoManager();
600
- const mathSerializationManager = new MathSerializationManager(mathRegistry);
601
-
602
- // Utility functions
603
- function generateMathId(): string {
604
- return `math_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
605
- }
606
-
607
- function createMathElement(node: MathNode): HTMLElement {
608
- const element = document.createElement('span');
609
- element.className = `math-formula math-${node.type}`;
610
- element.setAttribute('data-math-id', node.id);
611
- element.setAttribute('data-math-format', node.format);
612
- element.setAttribute('data-math-formula', node.formula);
613
- element.setAttribute('contenteditable', 'false');
614
- element.setAttribute('role', 'math');
615
- element.setAttribute('aria-label', node.semanticLabel);
616
-
617
- // Add rendered content
618
- element.innerHTML = node.renderedHtml;
619
-
620
- return element;
621
- }
622
-
623
- export const MathPlugin = (): Plugin => ({
624
- name: 'math',
625
- toolbar: [
626
- {
627
- label: 'Insert Math',
628
- command: 'insertMath',
629
- 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>'
630
- }
631
- ],
632
- // context: {
633
- // provider: MathProvider
634
- // }
635
- });
636
-
637
- // Clipboard Support - Phase 1 Implementation
638
- class MathClipboardManager {
639
- private registry: MathRegistry;
640
-
641
- constructor(registry: MathRegistry) {
642
- this.registry = registry;
643
- this.initializeClipboardHandlers();
644
- }
645
-
646
- private initializeClipboardHandlers(): void {
647
- document.addEventListener('copy', this.handleCopy.bind(this));
648
- document.addEventListener('paste', this.handlePaste.bind(this));
649
- }
650
-
651
- private handleCopy(event: ClipboardEvent): void {
652
- const selection = window.getSelection();
653
- if (!selection || selection.rangeCount === 0) return;
654
-
655
- const range = selection.getRangeAt(0);
656
- const mathElement = this.findMathElement(range.commonAncestorContainer);
657
-
658
- if (mathElement) {
659
- event.preventDefault();
660
-
661
- const mathId = mathElement.getAttribute('data-math-id');
662
- if (mathId) {
663
- const node = this.registry.get(mathId);
664
- if (node) {
665
- // Create HTML format for internal paste
666
- const htmlFormat = this.createHtmlClipboardFormat(node);
667
-
668
- // Create plain text fallback
669
- const textFormat = node.format === 'latex' ? node.formula : node.formula.replace(/<[^>]+>/g, '');
670
-
671
- if (event.clipboardData) {
672
- event.clipboardData.setData('text/html', htmlFormat);
673
- event.clipboardData.setData('text/plain', textFormat);
674
- }
675
- }
676
- }
677
- }
678
- }
679
-
680
- private handlePaste(event: ClipboardEvent): void {
681
- const clipboardData = event.clipboardData;
682
- if (!clipboardData) return;
683
-
684
- const htmlData = clipboardData.getData('text/html');
685
- const textData = clipboardData.getData('text/plain');
686
-
687
- // Check if it's our math paste format
688
- if (htmlData && htmlData.includes('data-math-format')) {
689
- event.preventDefault();
690
-
691
- const mathNode = this.parseHtmlClipboardFormat(htmlData);
692
- if (mathNode) {
693
- this.insertMathNodeAtCursor(mathNode);
694
- }
695
- }
696
- }
697
-
698
- private findMathElement(node: Node): HTMLElement | null {
699
- if (node.nodeType === Node.ELEMENT_NODE) {
700
- const element = node as HTMLElement;
701
- if (element.classList.contains('math-formula')) {
702
- return element;
703
- }
704
- }
705
-
706
- let current = node;
707
- while (current) {
708
- if (current.nodeType === Node.ELEMENT_NODE) {
709
- const element = current as HTMLElement;
710
- if (element.classList.contains('math-formula')) {
711
- return element;
712
- }
713
- }
714
- current = current.parentNode as Node;
715
- }
716
-
717
- return null;
718
- }
719
-
720
- private createHtmlClipboardFormat(node: MathNode): string {
721
- return `<span class="math-paste-data" data-math-format="${node.format}" data-math-formula="${this.escapeHtml(node.formula)}" data-math-type="${node.type}">${this.escapeHtml(node.formula)}</span>`;
722
- }
723
-
724
- private parseHtmlClipboardFormat(html: string): MathNode | null {
725
- const tempDiv = document.createElement('div');
726
- tempDiv.innerHTML = html;
727
-
728
- const pasteElement = tempDiv.querySelector('.math-paste-data') as HTMLElement;
729
- if (!pasteElement) return null;
730
-
731
- const format = pasteElement.getAttribute('data-math-format') as 'latex' | 'mathml';
732
- const formula = pasteElement.getAttribute('data-math-formula');
733
- const type = pasteElement.getAttribute('data-math-type') as 'inline' | 'block';
734
-
735
- if (!format || !formula || !type) return null;
736
-
737
- return {
738
- id: generateMathId(),
739
- type,
740
- format,
741
- formula,
742
- renderedHtml: '',
743
- semanticLabel: '',
744
- created: new Date(),
745
- modified: new Date()
746
- };
747
- }
748
-
749
- private insertMathNodeAtCursor(node: MathNode): void {
750
- const selection = window.getSelection();
751
- if (!selection || selection.rangeCount === 0) return;
752
-
753
- const range = selection.getRangeAt(0);
754
-
755
- // Render the node
756
- const renderResult = mathRenderer.render(node);
757
- node.renderedHtml = renderResult.html;
758
- node.semanticLabel = renderResult.semantic;
759
-
760
- // Create DOM element
761
- const element = createMathElement(node);
762
-
763
- // Insert at cursor
764
- range.insertNode(element);
765
-
766
- // Register the node
767
- mathRegistry.add(node);
768
-
769
- // Move cursor after the element
770
- range.setStartAfter(element);
771
- range.setEndAfter(element);
772
- selection.removeAllRanges();
773
- selection.addRange(range);
774
- }
775
-
776
- private escapeHtml(text: string): string {
777
- const div = document.createElement('div');
778
- div.textContent = text;
779
- return div.innerHTML;
780
- }
781
- }
782
-
783
- // Initialize clipboard manager
784
- const mathClipboardManager = new MathClipboardManager(mathRegistry);
785
-
786
- /**
787
- * Math Commands - Updated for new architecture
788
- */
789
-
790
- // Store selection when math command is triggered
791
- let storedMathSelection: Range | null = null;
792
-
793
- // Insert math command - opens dialog
794
- export const insertMathCommand = () => {
795
- // Store current selection before dialog opens
796
- const selection = window.getSelection();
797
- if (selection && selection.rangeCount > 0) {
798
- storedMathSelection = selection.getRangeAt(0).cloneRange();
799
- }
800
- // This will be handled by the MathProvider which manages the dialog state
801
- // The command triggers the dialog opening
802
- };
803
-
804
- // Update math command - for editing existing formulas
805
- export const updateMathCommand = (mathData: MathData, existingSpan?: HTMLElement) => {
806
- if (existingSpan) {
807
- // Editing existing math - update the span in place
808
- updateExistingMath(existingSpan, mathData);
809
- } else {
810
- // New math insertion
811
- applyMathToSelection(mathData);
812
- }
813
- // Clear stored selection after use
814
- storedMathSelection = null;
815
- };
816
-
817
- // Utility command for undo support (can be called from editor toolbar)
818
- export const undoMathOperation = () => {
819
- if (mathUndoManager.canUndo()) {
820
- mathUndoManager.undo();
821
- }
822
- };
823
-
824
- // Utility command to export all math content
825
- export const exportMathContent = (format: 'json' | 'html' = 'json'): string => {
826
- if (format === 'json') {
827
- return mathSerializationManager.exportToJSON();
828
- } else {
829
- return mathSerializationManager.exportToHTML();
830
- }
831
- };
832
-
833
- // Utility command to import math content
834
- export const importMathContent = (data: string, format: 'json' | 'html' = 'json') => {
835
- if (format === 'json') {
836
- return mathSerializationManager.importFromJSON(data);
837
- } else {
838
- return mathSerializationManager.importFromHTML(data);
839
- }
840
- };
841
-
842
- export interface MathData {
843
- formula: string;
844
- format: 'latex' | 'mathml';
845
- inline: boolean; // true for inline math, false for block math
846
- }
847
-
848
- /**
849
- * Helper function to apply math formula to stored selection
850
- */
851
- function applyMathToSelection(mathData: MathData) {
852
- // Use stored selection from before dialog opened
853
- const range = storedMathSelection;
854
- if (!range) {
855
- return;
856
- }
857
-
858
- const selection = window.getSelection();
859
- if (!selection) {
860
- return;
861
- }
862
-
863
- // Restore the stored selection
864
- selection.removeAllRanges();
865
- selection.addRange(range);
866
-
867
- // Create MathNode and render through MathRenderer for consistency
868
- const mathNode: MathNode = {
869
- id: generateMathId(),
870
- type: mathData.inline ? 'inline' : 'block',
871
- format: mathData.format,
872
- formula: mathData.formula,
873
- renderedHtml: '',
874
- semanticLabel: '',
875
- created: new Date(),
876
- modified: new Date()
877
- };
878
-
879
- // Render using the centralized MathRenderer
880
- const renderResult = mathRenderer.render(mathNode);
881
- mathNode.renderedHtml = renderResult.html;
882
- mathNode.semanticLabel = renderResult.semantic;
883
-
884
- // Create DOM element
885
- const mathSpan = createMathElement(mathNode);
886
-
887
- // For collapsed selections (just cursor), we'll still insert the math
888
- // For non-collapsed selections, we'll wrap the selected content
889
-
890
- let insertedElement: HTMLElement | null = null;
891
-
892
- if (mathData.inline) {
893
- // Use insertHTML command for safe insertion of complex HTML
894
- const selection = window.getSelection();
895
- if (selection && selection.rangeCount > 0) {
896
- const range = selection.getRangeAt(0);
897
-
898
- // Insert the math span at the cursor position
899
- range.insertNode(mathSpan);
900
- insertedElement = mathSpan;
901
-
902
- // Move cursor after the inserted math
903
- range.setStartAfter(mathSpan);
904
- range.setEndAfter(mathSpan);
905
- selection.removeAllRanges();
906
- selection.addRange(range);
907
- }
908
- } else {
909
- mathSpan.style.display = 'block';
910
- const pElement = document.createElement("p");
911
- pElement.innerHTML = mathSpan.outerHTML;
912
- const selection = window.getSelection();
913
- if (selection && selection.rangeCount > 0) {
914
- const range = selection.getRangeAt(0);
915
-
916
- // Insert the math span at the cursor position
917
- range.insertNode(pElement);
918
- insertedElement = pElement;
919
-
920
- // Move cursor after the inserted math
921
- range.setStartAfter(pElement);
922
- range.setEndAfter(pElement);
923
- selection.removeAllRanges();
924
- selection.addRange(range);
925
- }
926
-
927
- console.log("MathPlugin: block math inserted.");
928
- }
929
-
930
- // Register the node in the central registry
931
- mathRegistry.add(mathNode);
932
-
933
- // Record operation for undo/redo
934
- const mathOperation: MathOperation = {
935
- type: 'insert',
936
- nodeId: mathNode.id,
937
- before: null,
938
- after: mathNode,
939
- timestamp: new Date(),
940
- undo: () => {
941
- mathRegistry.delete(mathNode.id);
942
- if (insertedElement) {
943
- insertedElement.remove();
944
- }
945
- },
946
- redo: () => {
947
- mathRegistry.add(mathNode);
948
- if (insertedElement && insertedElement.parentNode) {
949
- insertedElement.parentNode.appendChild(insertedElement);
950
- }
951
- }
952
- };
953
- mathUndoManager.record(mathOperation);
954
-
955
- // Restore selection to after the inserted math (only for inline mode)
956
- if (insertedElement) {
957
- try {
958
- const newRange = document.createRange();
959
- newRange.setStartAfter(insertedElement);
960
- newRange.setEndAfter(insertedElement);
961
- const selection = window.getSelection();
962
- if (selection) {
963
- selection.removeAllRanges();
964
- selection.addRange(newRange);
965
- }
966
- } catch (error) {
967
- console.error('MathPlugin: Error restoring selection:', error);
968
- }
969
- }
970
- }
971
-
972
- /**
973
- * Helper function to update existing math span in place
974
- */
975
- function updateExistingMath(existingSpan: HTMLElement, mathData: MathData) {
976
- // Create MathNode from existing span data
977
- const mathId = existingSpan.getAttribute('data-math-id') || generateMathId();
978
-
979
- const mathNode: MathNode = {
980
- id: mathId,
981
- type: mathData.inline ? 'inline' : 'block',
982
- format: mathData.format,
983
- formula: mathData.formula,
984
- renderedHtml: '',
985
- semanticLabel: '',
986
- created: new Date(),
987
- modified: new Date()
988
- };
989
-
990
- // Render using the centralized MathRenderer
991
- const renderResult = mathRenderer.render(mathNode);
992
- mathNode.renderedHtml = renderResult.html;
993
- mathNode.semanticLabel = renderResult.semantic;
994
-
995
- // Clear existing content and update
996
- existingSpan.innerHTML = mathNode.renderedHtml;
997
- existingSpan.setAttribute('data-math-formula', mathData.formula);
998
- existingSpan.setAttribute('data-math-format', mathData.format);
999
- existingSpan.setAttribute('data-math-inline', mathData.inline.toString());
1000
- existingSpan.setAttribute('aria-label', mathNode.semanticLabel);
1001
- existingSpan.style.display = mathData.inline ? 'inline-block' : 'block';
1002
-
1003
- // Update in registry
1004
- mathRegistry.update(mathId, mathNode);
1005
-
1006
- // Record operation for undo/redo
1007
- const mathOperation: MathOperation = {
1008
- type: 'update',
1009
- nodeId: mathId,
1010
- before: mathRegistry.get(mathId) || null,
1011
- after: mathNode,
1012
- timestamp: new Date(),
1013
- undo: () => {
1014
- // Undo would restore previous state
1015
- const prevNode = mathRegistry.get(mathId);
1016
- if (prevNode) {
1017
- existingSpan.innerHTML = prevNode.renderedHtml;
1018
- }
1019
- },
1020
- redo: () => {
1021
- existingSpan.innerHTML = mathNode.renderedHtml;
1022
- }
1023
- };
1024
- mathUndoManager.record(mathOperation);
1025
- }
1026
-
1027
-
1028
-
1029
- /**
1030
- * Helper function to find the block ancestor of a node
1031
- */
1032
- function findBlockAncestor(node: Node): HTMLElement | null {
1033
- let current: Node | null = node;
1034
-
1035
- while (current) {
1036
- if (current.nodeType === Node.ELEMENT_NODE) {
1037
- const element = current as HTMLElement;
1038
- const tagName = element.tagName.toLowerCase();
1039
- if (['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'].includes(tagName)) {
1040
- return element;
1041
- }
1042
- }
1043
- current = current.parentNode;
1044
- }
1045
-
1046
- return null;
1047
- }
1048
-
1049
- // ============================================
1050
- // EXPORT PUBLIC API
1051
- // ============================================
1052
-
1053
- // Export main managers for external use and for internal access
1054
- export {
1055
- mathRegistry,
1056
- mathRenderer,
1057
- mathSelectionManager,
1058
- mathUndoManager,
1059
- mathSerializationManager,
1060
- mathClipboardManager
1061
- };
1062
-
1063
- /**
1064
- * Public API for Math Plugin Management
1065
- *
1066
- * Usage Examples:
1067
- *
1068
- * 1. Export all math content:
1069
- * const json = mathSerializationManager.exportToJSON();
1070
- *
1071
- * 2. Import math content:
1072
- * const result = mathSerializationManager.importFromJSON(jsonString);
1073
- *
1074
- * 3. Get all registered math nodes:
1075
- * const nodes = mathRegistry.getAll();
1076
- *
1077
- * 4. Subscribe to math node changes:
1078
- * const unsubscribe = mathRegistry.subscribe((node, action) => {
1079
- * console.log(`Math node ${action}:`, node);
1080
- * });
1081
- *
1082
- * 5. Undo last operation:
1083
- * if (mathUndoManager.canUndo()) {
1084
- * mathUndoManager.undo();
1085
- * }
1086
- *
1087
- * 6. Render a math formula:
1088
- * const mathNode: MathNode = {
1089
- * id: 'custom-id',
1090
- * type: 'inline',
1091
- * format: 'latex',
1092
- * formula: 'x^2 + y^2 = z^2',
1093
- * renderedHtml: '',
1094
- * semanticLabel: '',
1095
- * created: new Date(),
1096
- * modified: new Date()
1097
- * };
1098
- * const result = mathRenderer.render(mathNode);
1099
- */