@editora/spell-check 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,1423 @@
1
+ import { Plugin } from '@editora/core';
2
+
3
+ /**
4
+ * Spell Check Plugin - Native Implementation
5
+ *
6
+ * Features:
7
+ * - Real-time spell checking with visual highlights (red wavy underline)
8
+ * - Dictionary-based word validation (English words + custom dictionary)
9
+ * - Edit distance algorithm for intelligent suggestions
10
+ * - Right-click context menu on misspelled words with suggestions
11
+ * - Side panel with spell check stats and misspellings list
12
+ * - Ignore/Add to dictionary functionality
13
+ * - Protected contexts (code, comments, merge tags, URLs, emails)
14
+ * - Incremental spell checking with debouncing
15
+ * - MutationObserver for automatic re-checking
16
+ */
17
+
18
+ // ===== Core Data Structures =====
19
+
20
+ interface SpellCheckIssue {
21
+ id: string;
22
+ node: Text;
23
+ startOffset: number;
24
+ endOffset: number;
25
+ word: string;
26
+ suggestions: string[];
27
+ ignored?: boolean;
28
+ }
29
+
30
+ // English dictionary with common words
31
+ const ENGLISH_DICTIONARY = new Set([
32
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
33
+ 'of', 'with', 'by', 'from', 'is', 'are', 'be', 'was', 'were', 'have',
34
+ 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should',
35
+ 'may', 'might', 'must', 'can', 'this', 'that', 'these', 'those', 'what',
36
+ 'which', 'who', 'whom', 'where', 'when', 'why', 'how', 'all', 'each',
37
+ 'every', 'both', 'few', 'more', 'most', 'other', 'same', 'such', 'no',
38
+ 'nor', 'not', 'only', 'own', 'so', 'than', 'too', 'very', 'just', 'as',
39
+ 'if', 'because', 'while', 'although', 'though', 'it', 'its', 'their',
40
+ 'them', 'they', 'you', 'he', 'she', 'we', 'me', 'him', 'her', 'us', 'our',
41
+ 'i', 'my', 'your', 'his', 'hers', 'ours', 'yours', 'theirs', 'editor',
42
+ 'document', 'text', 'word', 'paragraph', 'line', 'page', 'content',
43
+ 'hello', 'world', 'test', 'example', 'sample', 'demo', 'lorem', 'ipsum'
44
+ ]);
45
+
46
+ const customDictionary = new Set<string>();
47
+ const ignoredWords = new Set<string>();
48
+
49
+ // Module-level state
50
+ let isSpellCheckEnabled = false;
51
+ let mutationObserver: MutationObserver | null = null;
52
+ let debounceTimeout: number | null = null;
53
+ let sidePanelElement: HTMLElement | null = null;
54
+ let activeEditorElement: HTMLElement | null = null;
55
+ let contextMenuListener: ((e: MouseEvent) => void) | null = null;
56
+ let isContextMenuAttached = false;
57
+ let pendingToggleEditorElement: HTMLElement | null = null;
58
+ let isToggleTriggerTrackingAttached = false;
59
+ let observerSuspendDepth = 0;
60
+
61
+ const MUTATION_OBSERVER_OPTIONS: MutationObserverInit = {
62
+ characterData: true,
63
+ childList: true,
64
+ subtree: true
65
+ };
66
+
67
+ const SPELLCHECK_STYLE_ID = 'rte-spellcheck-styles';
68
+ const COMMAND_EDITOR_CONTEXT_KEY = '__editoraCommandEditorRoot';
69
+
70
+ function consumeCommandEditorContextEditor(): HTMLElement | null {
71
+ if (typeof window === 'undefined') return null;
72
+
73
+ const explicitContext = (window as any)[COMMAND_EDITOR_CONTEXT_KEY] as HTMLElement | null | undefined;
74
+ if (!(explicitContext instanceof HTMLElement)) return null;
75
+
76
+ (window as any)[COMMAND_EDITOR_CONTEXT_KEY] = null;
77
+
78
+ const root =
79
+ (explicitContext.closest('[data-editora-editor], .rte-editor, .editora-editor, editora-editor') as HTMLElement | null) ||
80
+ (explicitContext.matches('[data-editora-editor], .rte-editor, .editora-editor, editora-editor')
81
+ ? explicitContext
82
+ : null);
83
+
84
+ if (root) {
85
+ const content = getEditorContentFromHost(root);
86
+ if (content) return content;
87
+ if (root.getAttribute('contenteditable') === 'true') return root;
88
+ }
89
+
90
+ if (explicitContext.getAttribute('contenteditable') === 'true') {
91
+ return explicitContext;
92
+ }
93
+
94
+ const nearestEditable = explicitContext.closest('[contenteditable="true"]');
95
+ return nearestEditable instanceof HTMLElement ? nearestEditable : null;
96
+ }
97
+
98
+ function ensureSpellCheckStyles(): void {
99
+ let style = document.getElementById(SPELLCHECK_STYLE_ID) as HTMLStyleElement | null;
100
+ if (!style) {
101
+ style = document.createElement('style');
102
+ style.id = SPELLCHECK_STYLE_ID;
103
+ document.head.appendChild(style);
104
+ }
105
+
106
+ style.textContent = `
107
+ .rte-spell-check-panel {
108
+ position: absolute;
109
+ top: 12px;
110
+ right: 12px;
111
+ width: min(360px, calc(100% - 24px));
112
+ max-height: min(560px, calc(100% - 24px));
113
+ overflow: hidden;
114
+ display: flex;
115
+ flex-direction: column;
116
+ border-radius: 12px;
117
+ border: 1px solid #d7dbe3;
118
+ background: #ffffff;
119
+ color: #1f2937;
120
+ box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
121
+ z-index: 1200;
122
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
123
+ }
124
+
125
+ .rte-spell-check-panel,
126
+ .rte-spell-check-panel * {
127
+ box-sizing: border-box;
128
+ }
129
+
130
+ .editora-theme-dark .rte-spell-check-panel {
131
+ border-color: #4b5563;
132
+ background: #1f2937;
133
+ color: #e5e7eb;
134
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
135
+ }
136
+
137
+ .rte-spellcheck-header {
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: space-between;
141
+ gap: 12px;
142
+ padding: 14px 16px 12px;
143
+ border-bottom: 1px solid #eceff5;
144
+ }
145
+
146
+ .editora-theme-dark .rte-spellcheck-header {
147
+ border-bottom-color: #374151;
148
+ }
149
+
150
+ .rte-spellcheck-title {
151
+ margin: 0;
152
+ font-size: 15px;
153
+ font-weight: 650;
154
+ }
155
+
156
+ .rte-spellcheck-subtitle {
157
+ margin: 2px 0 0;
158
+ font-size: 12px;
159
+ color: #64748b;
160
+ }
161
+
162
+ .editora-theme-dark .rte-spellcheck-subtitle {
163
+ color: #9ca3af;
164
+ }
165
+
166
+ .rte-spellcheck-close {
167
+ appearance: none;
168
+ border: none;
169
+ background: transparent;
170
+ font-size: 20px;
171
+ line-height: 1;
172
+ color: #6b7280;
173
+ cursor: pointer;
174
+ border-radius: 8px;
175
+ width: 30px;
176
+ height: 30px;
177
+ display: grid;
178
+ place-items: center;
179
+ }
180
+
181
+ .rte-spellcheck-close:hover {
182
+ background: rgba(15, 23, 42, 0.06);
183
+ color: #0f172a;
184
+ }
185
+
186
+ .editora-theme-dark .rte-spellcheck-close {
187
+ color: #9ca3af;
188
+ }
189
+
190
+ .editora-theme-dark .rte-spellcheck-close:hover {
191
+ background: rgba(255, 255, 255, 0.08);
192
+ color: #f3f4f6;
193
+ }
194
+
195
+ .rte-spellcheck-stats {
196
+ display: grid;
197
+ grid-template-columns: repeat(3, minmax(0, 1fr));
198
+ gap: 8px;
199
+ padding: 12px 16px;
200
+ border-bottom: 1px solid #eceff5;
201
+ }
202
+
203
+ .editora-theme-dark .rte-spellcheck-stats {
204
+ border-bottom-color: #374151;
205
+ }
206
+
207
+ .rte-spellcheck-stat {
208
+ border-radius: 10px;
209
+ background: #f8fafc;
210
+ border: 1px solid #e5e7eb;
211
+ padding: 8px 10px;
212
+ display: grid;
213
+ gap: 2px;
214
+ }
215
+
216
+ .editora-theme-dark .rte-spellcheck-stat {
217
+ background: #111827;
218
+ border-color: #374151;
219
+ }
220
+
221
+ .rte-spellcheck-stat-label {
222
+ font-size: 11px;
223
+ color: #64748b;
224
+ text-transform: uppercase;
225
+ letter-spacing: 0.04em;
226
+ }
227
+
228
+ .editora-theme-dark .rte-spellcheck-stat-label {
229
+ color: #9ca3af;
230
+ }
231
+
232
+ .rte-spellcheck-stat-value {
233
+ font-size: 16px;
234
+ font-weight: 700;
235
+ color: #111827;
236
+ }
237
+
238
+ .editora-theme-dark .rte-spellcheck-stat-value {
239
+ color: #f3f4f6;
240
+ }
241
+
242
+ .rte-spellcheck-list {
243
+ flex: 1 1 auto;
244
+ min-height: 0;
245
+ max-height: 100%;
246
+ overflow-y: auto;
247
+ overflow-x: hidden;
248
+ overscroll-behavior: contain;
249
+ padding: 10px 12px 12px;
250
+ display: grid;
251
+ gap: 8px;
252
+ scrollbar-width: thin;
253
+ scrollbar-color: #cbd5e1 #f1f5f9;
254
+ }
255
+
256
+ .rte-spellcheck-list::-webkit-scrollbar {
257
+ width: 10px;
258
+ }
259
+
260
+ .rte-spellcheck-list::-webkit-scrollbar-track {
261
+ background: #f1f5f9;
262
+ border-radius: 999px;
263
+ }
264
+
265
+ .rte-spellcheck-list::-webkit-scrollbar-thumb {
266
+ background: #cbd5e1;
267
+ border-radius: 999px;
268
+ border: 2px solid #f1f5f9;
269
+ }
270
+
271
+ .rte-spellcheck-list::-webkit-scrollbar-thumb:hover {
272
+ background: #94a3b8;
273
+ }
274
+
275
+ .rte-spellcheck-empty {
276
+ padding: 18px 14px;
277
+ text-align: center;
278
+ color: #64748b;
279
+ font-size: 13px;
280
+ border-radius: 10px;
281
+ border: 1px dashed #d1d5db;
282
+ background: #f8fafc;
283
+ }
284
+
285
+ .editora-theme-dark .rte-spellcheck-empty {
286
+ color: #9ca3af;
287
+ border-color: #4b5563;
288
+ background: #111827;
289
+ }
290
+
291
+ .editora-theme-dark .rte-spellcheck-list {
292
+ scrollbar-color: #4b5563 #1f2937;
293
+ }
294
+
295
+ .editora-theme-dark .rte-spellcheck-list::-webkit-scrollbar-track {
296
+ background: #1f2937;
297
+ }
298
+
299
+ .editora-theme-dark .rte-spellcheck-list::-webkit-scrollbar-thumb {
300
+ background: #4b5563;
301
+ border-color: #1f2937;
302
+ }
303
+
304
+ .editora-theme-dark .rte-spellcheck-list::-webkit-scrollbar-thumb:hover {
305
+ background: #64748b;
306
+ }
307
+
308
+ .rte-spellcheck-item {
309
+ border-radius: 10px;
310
+ border: 1px solid #e5e7eb;
311
+ background: #f8fafc;
312
+ overflow: visible;
313
+ }
314
+
315
+ .editora-theme-dark .rte-spellcheck-item {
316
+ border-color: #4b5563;
317
+ background: #111827;
318
+ }
319
+
320
+ .rte-spell-check-panel .rte-spellcheck-word-header {
321
+ all: unset;
322
+ width: 100%;
323
+ padding: 10px 11px;
324
+ display: flex;
325
+ align-items: center;
326
+ justify-content: space-between;
327
+ gap: 8px;
328
+ cursor: pointer;
329
+ text-align: left;
330
+ color: #111827;
331
+ font-size: 14px;
332
+ line-height: 1.35;
333
+ user-select: none;
334
+ opacity: 1;
335
+ visibility: visible;
336
+ }
337
+
338
+ .rte-spell-check-panel .rte-spellcheck-word {
339
+ font-weight: 700;
340
+ color: #c62828;
341
+ word-break: break-word;
342
+ flex: 1;
343
+ opacity: 1;
344
+ visibility: visible;
345
+ }
346
+
347
+ .editora-theme-dark .rte-spell-check-panel .rte-spellcheck-word-header {
348
+ color: #e5e7eb !important;
349
+ }
350
+
351
+ .editora-theme-dark .rte-spell-check-panel .rte-spellcheck-word {
352
+ color: #f87171 !important;
353
+ }
354
+
355
+ .rte-spell-check-panel .rte-spellcheck-caret {
356
+ color: #64748b;
357
+ font-size: 12px;
358
+ min-width: 12px;
359
+ text-align: right;
360
+ opacity: 1;
361
+ visibility: visible;
362
+ }
363
+
364
+ .rte-spell-check-panel .rte-spellcheck-suggestions {
365
+ display: none;
366
+ border-top: 1px solid #e5e7eb;
367
+ padding: 9px 11px 11px;
368
+ color: #334155;
369
+ font-size: 12px;
370
+ line-height: 1.4;
371
+ opacity: 1;
372
+ visibility: visible;
373
+ }
374
+
375
+ .editora-theme-dark .rte-spell-check-panel .rte-spellcheck-suggestions {
376
+ border-top-color: #374151;
377
+ color: #d1d5db !important;
378
+ }
379
+
380
+ .rte-spell-check-panel .rte-spellcheck-suggestions.show {
381
+ display: block;
382
+ }
383
+
384
+ .rte-spell-check-panel .rte-spellcheck-actions {
385
+ margin-top: 8px;
386
+ display: flex;
387
+ flex-wrap: wrap;
388
+ gap: 6px;
389
+ }
390
+
391
+ .rte-spell-check-panel .rte-spellcheck-btn {
392
+ all: unset;
393
+ border-radius: 8px;
394
+ border: 1px solid #d1d5db;
395
+ background: #fff;
396
+ color: #1f2937;
397
+ font-size: 12px;
398
+ font-weight: 550;
399
+ padding: 5px 8px;
400
+ cursor: pointer;
401
+ transition: all 0.15s ease;
402
+ display: inline-flex;
403
+ align-items: center;
404
+ justify-content: center;
405
+ opacity: 1;
406
+ visibility: visible;
407
+ }
408
+
409
+ .rte-spell-check-panel .rte-spellcheck-btn:hover {
410
+ background: #f3f4f6;
411
+ }
412
+
413
+ .rte-spell-check-panel .rte-spellcheck-btn.primary {
414
+ border-color: #2563eb;
415
+ background: #2563eb;
416
+ color: #fff;
417
+ }
418
+
419
+ .rte-spell-check-panel .rte-spellcheck-btn.primary:hover {
420
+ background: #1d4ed8;
421
+ }
422
+
423
+ .editora-theme-dark .rte-spell-check-panel .rte-spellcheck-btn {
424
+ border-color: #4b5563;
425
+ background: #1f2937;
426
+ color: #f3f4f6 !important;
427
+ }
428
+
429
+ .editora-theme-dark .rte-spell-check-panel .rte-spellcheck-btn:hover {
430
+ background: #374151;
431
+ }
432
+
433
+ .editora-theme-dark .rte-spell-check-panel .rte-spellcheck-btn.primary {
434
+ border-color: #60a5fa;
435
+ background: #2563eb;
436
+ color: #fff;
437
+ }
438
+
439
+ .rte-spellcheck-menu {
440
+ position: fixed;
441
+ background: #ffffff;
442
+ border: 1px solid #d1d5db;
443
+ border-radius: 10px;
444
+ box-shadow: 0 12px 30px rgba(15, 23, 42, 0.2);
445
+ z-index: 1300;
446
+ padding: 6px 0;
447
+ min-width: 180px;
448
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
449
+ font-size: 13px;
450
+ color: #111827;
451
+ }
452
+
453
+ .rte-spellcheck-menu-item {
454
+ padding: 8px 14px;
455
+ cursor: pointer;
456
+ transition: background 0.15s ease;
457
+ }
458
+
459
+ .rte-spellcheck-menu-item:hover {
460
+ background: #f3f4f6;
461
+ }
462
+
463
+ .rte-spellcheck-menu-item.meta {
464
+ color: #64748b;
465
+ }
466
+
467
+ .rte-spellcheck-menu-item.positive {
468
+ color: #1d4ed8;
469
+ }
470
+
471
+ .editora-theme-dark .rte-spellcheck-menu {
472
+ background: #1f2937;
473
+ border-color: #4b5563;
474
+ color: #e5e7eb;
475
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
476
+ }
477
+
478
+ .editora-theme-dark .rte-spellcheck-menu-item:hover {
479
+ background: #374151;
480
+ }
481
+
482
+ .editora-theme-dark .rte-spellcheck-menu-item.meta {
483
+ color: #9ca3af;
484
+ }
485
+
486
+ .editora-theme-dark .rte-spellcheck-menu-item.positive {
487
+ color: #93c5fd;
488
+ }
489
+
490
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spell-check-panel {
491
+ border-color: #4b5563;
492
+ background: #1f2937;
493
+ color: #e5e7eb;
494
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
495
+ }
496
+
497
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spellcheck-header {
498
+ border-bottom-color: #374151;
499
+ }
500
+
501
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spellcheck-subtitle {
502
+ color: #9ca3af;
503
+ }
504
+
505
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spellcheck-item {
506
+ border-color: #4b5563;
507
+ background: #111827;
508
+ }
509
+
510
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spellcheck-list {
511
+ scrollbar-color: #4b5563 #1f2937;
512
+ }
513
+
514
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spellcheck-list::-webkit-scrollbar-track {
515
+ background: #1f2937;
516
+ }
517
+
518
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spellcheck-list::-webkit-scrollbar-thumb {
519
+ background: #4b5563;
520
+ border-color: #1f2937;
521
+ }
522
+
523
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spellcheck-list::-webkit-scrollbar-thumb:hover {
524
+ background: #64748b;
525
+ }
526
+
527
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spell-check-panel .rte-spellcheck-word-header {
528
+ color: #e5e7eb !important;
529
+ }
530
+
531
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spell-check-panel .rte-spellcheck-word {
532
+ color: #f87171 !important;
533
+ }
534
+
535
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spell-check-panel .rte-spellcheck-suggestions {
536
+ border-top-color: #374151;
537
+ color: #d1d5db !important;
538
+ }
539
+
540
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spell-check-panel .rte-spellcheck-btn {
541
+ border-color: #4b5563;
542
+ background: #1f2937;
543
+ color: #f3f4f6 !important;
544
+ }
545
+
546
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spell-check-panel .rte-spellcheck-btn:hover {
547
+ background: #374151;
548
+ }
549
+
550
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spell-check-panel .rte-spellcheck-btn.primary {
551
+ border-color: #60a5fa;
552
+ background: #2563eb;
553
+ color: #fff;
554
+ }
555
+
556
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spellcheck-menu {
557
+ background: #1f2937;
558
+ border-color: #4b5563;
559
+ color: #e5e7eb;
560
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
561
+ }
562
+
563
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spellcheck-menu-item:hover {
564
+ background: #374151;
565
+ }
566
+
567
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spellcheck-menu-item.meta {
568
+ color: #9ca3af;
569
+ }
570
+
571
+ :is([theme="dark"], [data-theme="dark"], .dark, .editora-theme-dark) .rte-spellcheck-menu-item.positive {
572
+ color: #93c5fd;
573
+ }
574
+ `;
575
+ }
576
+
577
+ function getSpellcheckEditor(): HTMLElement | null {
578
+ if (activeEditorElement && document.contains(activeEditorElement)) {
579
+ return activeEditorElement;
580
+ }
581
+ const resolved = findActiveEditor();
582
+ if (resolved) activeEditorElement = resolved;
583
+ return resolved;
584
+ }
585
+
586
+ function setActiveEditorFromNode(node: Node | null): void {
587
+ if (!node) return;
588
+ const element = (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement) as HTMLElement | null;
589
+ const editor = element?.closest('[contenteditable="true"]') as HTMLElement | null;
590
+ if (editor) {
591
+ activeEditorElement = editor;
592
+ }
593
+ }
594
+
595
+ function getEditorContentFromHost(host: Element | null): HTMLElement | null {
596
+ if (!host) return null;
597
+ const content = host.querySelector('[contenteditable="true"]');
598
+ return content instanceof HTMLElement ? content : null;
599
+ }
600
+
601
+ function attachToggleTriggerTracking(): void {
602
+ if (isToggleTriggerTrackingAttached) return;
603
+
604
+ const handler = (event: Event) => {
605
+ const target = event.target as HTMLElement | null;
606
+ if (!target) return;
607
+
608
+ const trigger = target.closest(
609
+ '.editora-toolbar-button[data-command="toggleSpellCheck"], .rte-toolbar-button[data-command="toggleSpellCheck"]'
610
+ ) as HTMLElement | null;
611
+ if (!trigger) return;
612
+
613
+ const host = trigger.closest('[data-editora-editor]');
614
+ const editor = getEditorContentFromHost(host);
615
+ if (editor) {
616
+ pendingToggleEditorElement = editor;
617
+ activeEditorElement = editor;
618
+ }
619
+ };
620
+
621
+ document.addEventListener('pointerdown', handler, true);
622
+ isToggleTriggerTrackingAttached = true;
623
+ }
624
+
625
+ function resolveEditorForSpellCheckToggle(): HTMLElement | null {
626
+ const explicitContextEditor = consumeCommandEditorContextEditor();
627
+ if (explicitContextEditor && document.contains(explicitContextEditor)) {
628
+ activeEditorElement = explicitContextEditor;
629
+ pendingToggleEditorElement = null;
630
+ return explicitContextEditor;
631
+ }
632
+
633
+ if (pendingToggleEditorElement && document.contains(pendingToggleEditorElement)) {
634
+ const editor = pendingToggleEditorElement;
635
+ pendingToggleEditorElement = null;
636
+ activeEditorElement = editor;
637
+ return editor;
638
+ }
639
+ return findActiveEditor();
640
+ }
641
+
642
+ function suspendMutationObserver(): void {
643
+ observerSuspendDepth += 1;
644
+ if (observerSuspendDepth === 1 && mutationObserver) {
645
+ mutationObserver.disconnect();
646
+ }
647
+ }
648
+
649
+ function resumeMutationObserver(): void {
650
+ if (observerSuspendDepth === 0) return;
651
+ observerSuspendDepth -= 1;
652
+ if (observerSuspendDepth > 0) return;
653
+ if (!mutationObserver) return;
654
+
655
+ const editor = getSpellcheckEditor();
656
+ if (editor) {
657
+ mutationObserver.observe(editor, MUTATION_OBSERVER_OPTIONS);
658
+ }
659
+ }
660
+
661
+ function runWithObserverSuspended<T>(operation: () => T): T {
662
+ suspendMutationObserver();
663
+ try {
664
+ return operation();
665
+ } finally {
666
+ resumeMutationObserver();
667
+ }
668
+ }
669
+
670
+ // Load custom dictionary from localStorage
671
+ const loadCustomDictionary = () => {
672
+ try {
673
+ const saved = localStorage.getItem('rte-custom-dictionary');
674
+ if (saved) {
675
+ const words = JSON.parse(saved) as string[];
676
+ words.forEach(word => customDictionary.add(word.toLowerCase()));
677
+ }
678
+ } catch (e) {
679
+ console.warn('Failed to load custom dictionary:', e);
680
+ }
681
+ };
682
+
683
+ // Save custom dictionary to localStorage
684
+ const saveCustomDictionary = () => {
685
+ try {
686
+ const words = Array.from(customDictionary);
687
+ localStorage.setItem('rte-custom-dictionary', JSON.stringify(words));
688
+ } catch (e) {
689
+ console.warn('Failed to save custom dictionary:', e);
690
+ }
691
+ };
692
+
693
+ // ===== Spell Check Engine =====
694
+
695
+ /**
696
+ * Edit distance algorithm (Levenshtein distance) for suggestions
697
+ */
698
+ function editDistance(a: string, b: string): number {
699
+ const matrix: number[][] = [];
700
+ for (let i = 0; i <= b.length; i++) matrix[i] = [i];
701
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
702
+ for (let i = 1; i <= b.length; i++) {
703
+ for (let j = 1; j <= a.length; j++) {
704
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
705
+ matrix[i][j] = matrix[i - 1][j - 1];
706
+ } else {
707
+ matrix[i][j] = Math.min(
708
+ matrix[i - 1][j - 1] + 1, // substitution
709
+ matrix[i][j - 1] + 1, // insertion
710
+ matrix[i - 1][j] + 1 // deletion
711
+ );
712
+ }
713
+ }
714
+ }
715
+ return matrix[b.length][a.length];
716
+ }
717
+
718
+ /**
719
+ * Check if word is in dictionary
720
+ */
721
+ function checkWord(word: string): boolean {
722
+ const w = word.toLowerCase();
723
+ return ENGLISH_DICTIONARY.has(w) || customDictionary.has(w) || ignoredWords.has(w);
724
+ }
725
+
726
+ /**
727
+ * Get spelling suggestions using edit distance
728
+ */
729
+ function getSuggestions(word: string, maxSuggestions = 5): string[] {
730
+ const wordLower = word.toLowerCase();
731
+ const words = Array.from(ENGLISH_DICTIONARY);
732
+ const distances = words.map(w => ({ word: w, distance: editDistance(wordLower, w) }));
733
+ distances.sort((a, b) => a.distance - b.distance);
734
+ return distances.filter(d => d.distance <= 3).slice(0, maxSuggestions).map(d => d.word);
735
+ }
736
+
737
+ /**
738
+ * Check if a node is in a protected context (code, comments, etc.)
739
+ */
740
+ function isProtected(node: Node): boolean {
741
+ if (node.nodeType !== Node.ELEMENT_NODE) return false;
742
+ const el = node as HTMLElement;
743
+ if (
744
+ el.closest('code, pre, [contenteditable="false"], .rte-widget, .rte-template, .rte-comment, .rte-merge-tag') ||
745
+ el.hasAttribute('data-comment-id') ||
746
+ el.hasAttribute('data-template') ||
747
+ el.hasAttribute('data-merge-tag')
748
+ ) return true;
749
+ return false;
750
+ }
751
+
752
+ /**
753
+ * Tokenize a text node and find misspellings
754
+ */
755
+ function tokenizeTextNode(node: Text): SpellCheckIssue[] {
756
+ const issues: SpellCheckIssue[] = [];
757
+ const wordRegex = /([\p{L}\p{M}\p{N}\p{Emoji_Presentation}\u200d'-]+|[\uD800-\uDBFF][\uDC00-\uDFFF])/gu;
758
+ let match;
759
+
760
+ while ((match = wordRegex.exec(node.data)) !== null) {
761
+ const word = match[0];
762
+ const start = match.index;
763
+ const end = start + word.length;
764
+
765
+ // Skip URLs, emails, merge tags, and numbers
766
+ if (/https?:\/\//.test(word) || /@/.test(word) || /\{\{.*\}\}/.test(word) || /^\d+$/.test(word)) continue;
767
+
768
+ // Skip already correct words
769
+ if (checkWord(word)) continue;
770
+
771
+ // Skip camelCase, hyphenated words, and proper nouns (capitalized)
772
+ if (/[a-z][A-Z]/.test(word) || /-/.test(word) || (word[0] === word[0].toUpperCase() && word.length > 1)) continue;
773
+
774
+ issues.push({
775
+ id: `${word}-${start}`,
776
+ node,
777
+ startOffset: start,
778
+ endOffset: end,
779
+ word,
780
+ suggestions: getSuggestions(word),
781
+ ignored: false
782
+ });
783
+ }
784
+
785
+ return issues;
786
+ }
787
+
788
+ /**
789
+ * Find the active editor element
790
+ */
791
+ const findActiveEditor = (): HTMLElement | null => {
792
+ const explicitContextEditor = consumeCommandEditorContextEditor();
793
+ if (explicitContextEditor && document.contains(explicitContextEditor)) {
794
+ activeEditorElement = explicitContextEditor;
795
+ return explicitContextEditor;
796
+ }
797
+
798
+ // Try to find editor from current selection
799
+ const selection = window.getSelection();
800
+ if (selection && selection.rangeCount > 0) {
801
+ let node: Node | null = selection.getRangeAt(0).startContainer;
802
+ while (node && node !== document.body) {
803
+ if (node.nodeType === Node.ELEMENT_NODE) {
804
+ const element = node as HTMLElement;
805
+ if (element.getAttribute('contenteditable') === 'true') {
806
+ return element;
807
+ }
808
+ }
809
+ node = node.parentNode;
810
+ }
811
+ }
812
+
813
+ // Try active element
814
+ const activeElement = document.activeElement;
815
+ if (activeElement) {
816
+ if (activeElement.getAttribute('contenteditable') === 'true') {
817
+ return activeElement as HTMLElement;
818
+ }
819
+ const editor = activeElement.closest('[contenteditable="true"]');
820
+ if (editor) return editor as HTMLElement;
821
+
822
+ // If focus is on toolbar controls, resolve the editor inside the same host.
823
+ const hostEditor = activeElement.closest('[data-editora-editor]');
824
+ if (hostEditor) {
825
+ const content = hostEditor.querySelector('[contenteditable="true"]');
826
+ if (content) return content as HTMLElement;
827
+ }
828
+ }
829
+
830
+ // Fallback to first editor
831
+ return document.querySelector('[contenteditable="true"]');
832
+ };
833
+
834
+ /**
835
+ * Scan entire document for misspellings
836
+ */
837
+ function scanDocumentForMisspellings(): SpellCheckIssue[] {
838
+ const editor = getSpellcheckEditor();
839
+ if (!editor) return [];
840
+
841
+ const issues: SpellCheckIssue[] = [];
842
+ const walker = document.createTreeWalker(
843
+ editor,
844
+ NodeFilter.SHOW_TEXT,
845
+ {
846
+ acceptNode: (node) => {
847
+ if (!node.textContent?.trim()) return NodeFilter.FILTER_REJECT;
848
+ if (node.parentNode && isProtected(node.parentNode)) return NodeFilter.FILTER_REJECT;
849
+ return NodeFilter.FILTER_ACCEPT;
850
+ }
851
+ }
852
+ );
853
+
854
+ let textNode = walker.nextNode() as Text;
855
+ while (textNode) {
856
+ issues.push(...tokenizeTextNode(textNode));
857
+ textNode = walker.nextNode() as Text;
858
+ }
859
+
860
+ return issues;
861
+ }
862
+
863
+ /**
864
+ * Highlight misspelled words with red wavy underline
865
+ */
866
+ function highlightMisspelledWords(issues?: SpellCheckIssue[]): void {
867
+ const editor = getSpellcheckEditor();
868
+ if (!editor) return;
869
+
870
+ if (!issues) issues = scanDocumentForMisspellings();
871
+
872
+ runWithObserverSuspended(() => {
873
+ // Clear existing highlights
874
+ editor.querySelectorAll('.rte-misspelled').forEach(el => {
875
+ const parent = el.parentNode;
876
+ if (parent) {
877
+ while (el.firstChild) {
878
+ parent.insertBefore(el.firstChild, el);
879
+ }
880
+ parent.removeChild(el);
881
+ }
882
+ });
883
+
884
+ // Add new highlights
885
+ issues!.forEach(issue => {
886
+ if (ignoredWords.has(issue.word.toLowerCase())) return;
887
+
888
+ // Defensive check: ensure offsets are within bounds
889
+ const nodeLength = issue.node.data.length;
890
+ if (
891
+ issue.startOffset < 0 ||
892
+ issue.endOffset > nodeLength ||
893
+ issue.startOffset >= issue.endOffset
894
+ ) {
895
+ return;
896
+ }
897
+
898
+ try {
899
+ const range = document.createRange();
900
+ range.setStart(issue.node, issue.startOffset);
901
+ range.setEnd(issue.node, issue.endOffset);
902
+
903
+ const span = document.createElement('span');
904
+ span.className = 'rte-misspelled';
905
+ span.setAttribute('data-word', issue.word);
906
+ span.setAttribute('data-suggestions', issue.suggestions.join(','));
907
+ span.setAttribute('title', `Suggestions: ${issue.suggestions.join(', ')}`);
908
+ span.style.borderBottom = '2px wavy red';
909
+ span.style.cursor = 'pointer';
910
+
911
+ range.surroundContents(span);
912
+ } catch (e) {
913
+ // Skip if range surroundContents fails
914
+ }
915
+ });
916
+ });
917
+
918
+ // Update side panel with precomputed issues to avoid an extra scan
919
+ updateSidePanel(issues);
920
+ }
921
+
922
+ /**
923
+ * Clear all spell check highlights
924
+ */
925
+ function clearSpellCheckHighlights(): void {
926
+ const editor = getSpellcheckEditor();
927
+ if (!editor) return;
928
+
929
+ runWithObserverSuspended(() => {
930
+ editor.querySelectorAll('.rte-misspelled').forEach(el => {
931
+ const parent = el.parentNode;
932
+ if (parent) {
933
+ while (el.firstChild) {
934
+ parent.insertBefore(el.firstChild, el);
935
+ }
936
+ parent.removeChild(el);
937
+ }
938
+ });
939
+ });
940
+ }
941
+
942
+ /**
943
+ * Replace a misspelled word with a suggestion
944
+ */
945
+ function replaceWord(issue: SpellCheckIssue, replacement: string): void {
946
+ runWithObserverSuspended(() => {
947
+ const range = document.createRange();
948
+ range.setStart(issue.node, issue.startOffset);
949
+ range.setEnd(issue.node, issue.endOffset);
950
+
951
+ const textNode = document.createTextNode(replacement);
952
+ range.deleteContents();
953
+ range.insertNode(textNode);
954
+ });
955
+ }
956
+
957
+ /**
958
+ * Ignore a word (session only)
959
+ */
960
+ function ignoreWord(word: string): void {
961
+ ignoredWords.add(word.toLowerCase());
962
+ clearSpellCheckHighlights();
963
+ highlightMisspelledWords();
964
+ }
965
+
966
+ /**
967
+ * Add word to custom dictionary (persistent)
968
+ */
969
+ function addToDictionary(word: string): void {
970
+ customDictionary.add(word.toLowerCase());
971
+ saveCustomDictionary(); // Save to localStorage
972
+ clearSpellCheckHighlights();
973
+ highlightMisspelledWords();
974
+ }
975
+
976
+ /**
977
+ * Get spell check statistics
978
+ */
979
+ function getSpellCheckStats(issues?: SpellCheckIssue[]): { total: number; misspelled: number; accuracy: number } {
980
+ const editor = getSpellcheckEditor();
981
+ if (!editor) return { total: 0, misspelled: 0, accuracy: 100 };
982
+
983
+ // Use provided issues or scan if not provided
984
+ if (!issues) {
985
+ issues = scanDocumentForMisspellings();
986
+ }
987
+ const misspelled = issues.filter(i => !ignoredWords.has(i.word.toLowerCase())).length;
988
+
989
+ // Count total words
990
+ const text = editor.textContent || '';
991
+ const words = text.match(/[\p{L}\p{M}\p{N}]+/gu) || [];
992
+ const total = words.length;
993
+
994
+ return {
995
+ total,
996
+ misspelled,
997
+ accuracy: total > 0 ? ((total - misspelled) / total) * 100 : 100
998
+ };
999
+ }
1000
+
1001
+ function replaceMisspelledTarget(target: HTMLElement, replacement: string): void {
1002
+ const textNode = document.createTextNode(replacement);
1003
+ target.replaceWith(textNode);
1004
+ }
1005
+
1006
+ function ignoreMisspelledTargetOnce(target: HTMLElement): void {
1007
+ target.classList.remove('rte-misspelled');
1008
+ target.removeAttribute('data-word');
1009
+ target.removeAttribute('data-suggestions');
1010
+ target.removeAttribute('title');
1011
+ target.style.borderBottom = '';
1012
+ target.style.cursor = '';
1013
+ }
1014
+
1015
+ // ===== Context Menu =====
1016
+
1017
+ /**
1018
+ * Show context menu for misspelled word
1019
+ */
1020
+ function showSpellCheckContextMenu(x: number, y: number, word: string, suggestions: string[], target: HTMLElement): void {
1021
+ setActiveEditorFromNode(target);
1022
+
1023
+ // Remove existing menus
1024
+ document.querySelectorAll('.rte-spellcheck-menu').forEach(el => el.remove());
1025
+
1026
+ const menu = document.createElement('div');
1027
+ menu.className = 'rte-spellcheck-menu';
1028
+
1029
+ // Add suggestions
1030
+ suggestions.slice(0, 5).forEach(suggestion => {
1031
+ const item = document.createElement('div');
1032
+ item.className = 'rte-spellcheck-menu-item';
1033
+ item.textContent = suggestion;
1034
+ item.onclick = () => {
1035
+ replaceMisspelledTarget(target, suggestion);
1036
+ window.setTimeout(() => {
1037
+ if (isSpellCheckEnabled) {
1038
+ highlightMisspelledWords();
1039
+ updateSidePanel();
1040
+ }
1041
+ }, 0);
1042
+ menu.remove();
1043
+ };
1044
+ menu.appendChild(item);
1045
+ });
1046
+
1047
+ // Separator
1048
+ if (suggestions.length > 0) {
1049
+ const separator = document.createElement('div');
1050
+ separator.style.cssText = 'height: 1px; background: #ddd; margin: 4px 0;';
1051
+ menu.appendChild(separator);
1052
+ }
1053
+
1054
+ // Ignore Once
1055
+ const ignoreOnce = document.createElement('div');
1056
+ ignoreOnce.className = 'rte-spellcheck-menu-item meta';
1057
+ ignoreOnce.textContent = 'Ignore Once';
1058
+ ignoreOnce.onclick = () => {
1059
+ ignoreMisspelledTargetOnce(target);
1060
+ menu.remove();
1061
+ };
1062
+ menu.appendChild(ignoreOnce);
1063
+
1064
+ // Ignore All
1065
+ const ignoreAll = document.createElement('div');
1066
+ ignoreAll.className = 'rte-spellcheck-menu-item meta';
1067
+ ignoreAll.textContent = 'Ignore All';
1068
+ ignoreAll.onclick = () => {
1069
+ ignoreWord(word);
1070
+ menu.remove();
1071
+ };
1072
+ menu.appendChild(ignoreAll);
1073
+
1074
+ // Add to Dictionary
1075
+ const addToDict = document.createElement('div');
1076
+ addToDict.className = 'rte-spellcheck-menu-item positive';
1077
+ addToDict.textContent = 'Add to Dictionary';
1078
+ addToDict.onclick = () => {
1079
+ addToDictionary(word);
1080
+ menu.remove();
1081
+ };
1082
+ menu.appendChild(addToDict);
1083
+
1084
+ document.body.appendChild(menu);
1085
+
1086
+ const menuRect = menu.getBoundingClientRect();
1087
+ const maxLeft = window.innerWidth - menuRect.width - 8;
1088
+ const maxTop = window.innerHeight - menuRect.height - 8;
1089
+ menu.style.left = `${Math.max(8, Math.min(x, maxLeft))}px`;
1090
+ menu.style.top = `${Math.max(8, Math.min(y, maxTop))}px`;
1091
+
1092
+ // Close on click outside
1093
+ const dismiss = (ev: MouseEvent) => {
1094
+ if (!menu.contains(ev.target as Node)) {
1095
+ menu.remove();
1096
+ document.removeEventListener('mousedown', dismiss);
1097
+ }
1098
+ };
1099
+ setTimeout(() => document.addEventListener('mousedown', dismiss), 0);
1100
+ }
1101
+
1102
+ /**
1103
+ * Attach context menu to document
1104
+ */
1105
+ function attachSpellCheckContextMenu(): void {
1106
+ if (isContextMenuAttached) return;
1107
+
1108
+ contextMenuListener = (e: MouseEvent) => {
1109
+ const target = e.target as HTMLElement;
1110
+ if (target && target.classList.contains('rte-misspelled')) {
1111
+ e.preventDefault();
1112
+ setActiveEditorFromNode(target);
1113
+ const word = target.getAttribute('data-word')!;
1114
+ const suggestions = (target.getAttribute('data-suggestions') || '').split(',').filter(s => s);
1115
+ showSpellCheckContextMenu(e.clientX, e.clientY, word, suggestions, target);
1116
+ }
1117
+ };
1118
+
1119
+ document.addEventListener('contextmenu', contextMenuListener);
1120
+ isContextMenuAttached = true;
1121
+ }
1122
+
1123
+ function detachSpellCheckContextMenu(): void {
1124
+ if (!isContextMenuAttached || !contextMenuListener) return;
1125
+ document.removeEventListener('contextmenu', contextMenuListener);
1126
+ contextMenuListener = null;
1127
+ isContextMenuAttached = false;
1128
+ }
1129
+
1130
+ // ===== Side Panel =====
1131
+
1132
+ function getEditorHost(editor: HTMLElement): HTMLElement {
1133
+ const host = editor.closest('[data-editora-editor]') as HTMLElement | null;
1134
+ return host || editor.parentElement || editor;
1135
+ }
1136
+
1137
+ /**
1138
+ * Create and show side panel
1139
+ */
1140
+ function createSidePanel(): HTMLElement {
1141
+ const editor = getSpellcheckEditor();
1142
+ if (!editor) {
1143
+ throw new Error('Spell check panel requested without active editor');
1144
+ }
1145
+
1146
+ const host = getEditorHost(editor);
1147
+ ensureSpellCheckStyles();
1148
+
1149
+ const panel = document.createElement('div');
1150
+ panel.className = 'rte-spell-check-panel';
1151
+
1152
+ const computedHostStyle = window.getComputedStyle(host);
1153
+ if (computedHostStyle.position === 'static') {
1154
+ host.style.position = 'relative';
1155
+ }
1156
+
1157
+ host.appendChild(panel);
1158
+ return panel;
1159
+ }
1160
+
1161
+ /**
1162
+ * Update side panel content
1163
+ */
1164
+ function updateSidePanel(precomputedIssues?: SpellCheckIssue[]): void {
1165
+ if (!sidePanelElement) return;
1166
+
1167
+ const issues = precomputedIssues || scanDocumentForMisspellings();
1168
+ const stats = getSpellCheckStats(issues); // Pass issues to avoid duplicate scan
1169
+
1170
+ sidePanelElement.innerHTML = `
1171
+ <div class="rte-spellcheck-header">
1172
+ <div>
1173
+ <h3 class="rte-spellcheck-title">Spell Check</h3>
1174
+ <p class="rte-spellcheck-subtitle">Review suggestions and resolve issues quickly</p>
1175
+ </div>
1176
+ <button class="rte-spellcheck-close" aria-label="Close spell check panel">✕</button>
1177
+ </div>
1178
+
1179
+ <div class="rte-spellcheck-stats">
1180
+ <div class="rte-spellcheck-stat">
1181
+ <span class="rte-spellcheck-stat-label">Total</span>
1182
+ <strong class="rte-spellcheck-stat-value">${stats.total}</strong>
1183
+ </div>
1184
+ <div class="rte-spellcheck-stat">
1185
+ <span class="rte-spellcheck-stat-label">Misspelled</span>
1186
+ <strong class="rte-spellcheck-stat-value">${stats.misspelled}</strong>
1187
+ </div>
1188
+ <div class="rte-spellcheck-stat">
1189
+ <span class="rte-spellcheck-stat-label">Accuracy</span>
1190
+ <strong class="rte-spellcheck-stat-value">${stats.accuracy.toFixed(1)}%</strong>
1191
+ </div>
1192
+ </div>
1193
+
1194
+ <div class="rte-spellcheck-list">
1195
+ ${issues.length === 0
1196
+ ? '<div class="rte-spellcheck-empty">No spelling errors found in this editor.</div>'
1197
+ : issues.map((issue, idx) => `
1198
+ <div class="rte-spellcheck-item" data-word="${issue.word}" data-index="${idx}">
1199
+ <button class="rte-spellcheck-word-header" type="button">
1200
+ <span class="rte-spellcheck-word">${issue.word}</span>
1201
+ <span class="rte-spellcheck-caret">▶</span>
1202
+ </button>
1203
+ <div class="rte-spellcheck-suggestions">
1204
+ ${issue.suggestions.length > 0
1205
+ ? `<div class="rte-spellcheck-actions">
1206
+ ${issue.suggestions.map(s => `<button class="rte-spellcheck-btn primary suggestion-btn" data-suggestion="${s}" type="button">${s}</button>`).join('')}
1207
+ </div>`
1208
+ : '<div class="rte-spellcheck-subtitle">No suggestions available</div>'
1209
+ }
1210
+ <div class="rte-spellcheck-actions">
1211
+ <button class="rte-spellcheck-btn ignore-btn" type="button">Ignore</button>
1212
+ <button class="rte-spellcheck-btn add-btn" type="button">Add to Dictionary</button>
1213
+ </div>
1214
+ </div>
1215
+ </div>
1216
+ `).join('')
1217
+ }
1218
+ </div>
1219
+ `;
1220
+
1221
+ // Event listeners
1222
+ const closeBtn = sidePanelElement.querySelector('.rte-spellcheck-close');
1223
+ closeBtn?.addEventListener('click', (event) => {
1224
+ event.preventDefault();
1225
+ event.stopPropagation();
1226
+ disableSpellCheck();
1227
+ });
1228
+
1229
+ // Toggle expand/collapse
1230
+ sidePanelElement.querySelectorAll('.rte-spellcheck-word-header').forEach((header) => {
1231
+ header.addEventListener('click', () => {
1232
+ const item = header.closest('.rte-spellcheck-item');
1233
+ const suggestions = item?.querySelector('.rte-spellcheck-suggestions') as HTMLElement;
1234
+ const expandBtn = header.querySelector('.rte-spellcheck-caret');
1235
+ if (suggestions && expandBtn) {
1236
+ if (!suggestions.classList.contains('show')) {
1237
+ suggestions.classList.add('show');
1238
+ expandBtn.textContent = '▼';
1239
+ } else {
1240
+ suggestions.classList.remove('show');
1241
+ expandBtn.textContent = '▶';
1242
+ }
1243
+ }
1244
+ });
1245
+ });
1246
+
1247
+ // Suggestion buttons
1248
+ sidePanelElement.querySelectorAll('.suggestion-btn').forEach(btn => {
1249
+ btn.addEventListener('click', () => {
1250
+ const suggestion = btn.getAttribute('data-suggestion')!;
1251
+ const item = btn.closest('.rte-spellcheck-item');
1252
+ const word = item?.getAttribute('data-word')!;
1253
+ const issueIndex = parseInt(item?.getAttribute('data-index') || '0');
1254
+
1255
+ if (issues[issueIndex]) {
1256
+ replaceWord(issues[issueIndex], suggestion);
1257
+ highlightMisspelledWords();
1258
+ }
1259
+ });
1260
+ });
1261
+
1262
+ // Ignore buttons
1263
+ sidePanelElement.querySelectorAll('.ignore-btn').forEach(btn => {
1264
+ btn.addEventListener('click', () => {
1265
+ const item = btn.closest('.rte-spellcheck-item');
1266
+ const word = item?.getAttribute('data-word')!;
1267
+ ignoreWord(word);
1268
+ });
1269
+ });
1270
+
1271
+ // Add to dictionary buttons
1272
+ sidePanelElement.querySelectorAll('.add-btn').forEach(btn => {
1273
+ btn.addEventListener('click', () => {
1274
+ const item = btn.closest('.rte-spellcheck-item');
1275
+ const word = item?.getAttribute('data-word')!;
1276
+ addToDictionary(word);
1277
+ });
1278
+ });
1279
+ }
1280
+
1281
+ // ===== MutationObserver for incremental spell check =====
1282
+
1283
+ function startMutationObserver(): void {
1284
+ const editor = getSpellcheckEditor();
1285
+ if (!editor) return;
1286
+
1287
+ if (mutationObserver) mutationObserver.disconnect();
1288
+
1289
+ mutationObserver = new MutationObserver((mutations) => {
1290
+ if (observerSuspendDepth > 0) return;
1291
+
1292
+ // Only trigger on text/content changes
1293
+ if (mutations.some(m => m.type === 'characterData' || m.type === 'childList')) {
1294
+ // Debounce the highlight function
1295
+ if (debounceTimeout) clearTimeout(debounceTimeout);
1296
+ debounceTimeout = window.setTimeout(() => {
1297
+ if (isSpellCheckEnabled) {
1298
+ highlightMisspelledWords();
1299
+ }
1300
+ }, 350);
1301
+ }
1302
+ });
1303
+
1304
+ mutationObserver.observe(editor, {
1305
+ ...MUTATION_OBSERVER_OPTIONS
1306
+ });
1307
+ }
1308
+
1309
+ function stopMutationObserver(): void {
1310
+ if (mutationObserver) {
1311
+ mutationObserver.disconnect();
1312
+ mutationObserver = null;
1313
+ }
1314
+ if (debounceTimeout) {
1315
+ clearTimeout(debounceTimeout);
1316
+ debounceTimeout = null;
1317
+ }
1318
+ }
1319
+
1320
+ function removeSpellcheckMenus(): void {
1321
+ document.querySelectorAll('.rte-spellcheck-menu').forEach(el => el.remove());
1322
+ }
1323
+
1324
+ function disableSpellCheck(): boolean {
1325
+ if (!isSpellCheckEnabled) return false;
1326
+
1327
+ clearSpellCheckHighlights();
1328
+ stopMutationObserver();
1329
+ detachSpellCheckContextMenu();
1330
+ removeSpellcheckMenus();
1331
+
1332
+ if (sidePanelElement) {
1333
+ sidePanelElement.remove();
1334
+ sidePanelElement = null;
1335
+ }
1336
+
1337
+ activeEditorElement = null;
1338
+ pendingToggleEditorElement = null;
1339
+ isSpellCheckEnabled = false;
1340
+ return false;
1341
+ }
1342
+
1343
+ // ===== Main Toggle Function =====
1344
+
1345
+ function toggleSpellCheck(): boolean {
1346
+ const targetEditor = resolveEditorForSpellCheckToggle();
1347
+ if (!targetEditor) return false;
1348
+
1349
+ // If spell check is already enabled on another editor, switch instance
1350
+ // in one action instead of forcing disable + enable.
1351
+ if (isSpellCheckEnabled && activeEditorElement && activeEditorElement !== targetEditor) {
1352
+ clearSpellCheckHighlights();
1353
+ stopMutationObserver();
1354
+ removeSpellcheckMenus();
1355
+
1356
+ if (sidePanelElement) {
1357
+ sidePanelElement.remove();
1358
+ sidePanelElement = null;
1359
+ }
1360
+
1361
+ activeEditorElement = targetEditor;
1362
+ ensureSpellCheckStyles();
1363
+ attachSpellCheckContextMenu();
1364
+ highlightMisspelledWords();
1365
+ startMutationObserver();
1366
+ sidePanelElement = createSidePanel();
1367
+ updateSidePanel();
1368
+ return true;
1369
+ }
1370
+
1371
+ // Toggle off when already enabled on the same editor
1372
+ if (isSpellCheckEnabled) {
1373
+ return disableSpellCheck();
1374
+ }
1375
+
1376
+ // Enable on resolved target editor
1377
+ activeEditorElement = targetEditor;
1378
+ isSpellCheckEnabled = true;
1379
+ ensureSpellCheckStyles();
1380
+ attachSpellCheckContextMenu();
1381
+ highlightMisspelledWords();
1382
+ startMutationObserver();
1383
+
1384
+ if (sidePanelElement) {
1385
+ sidePanelElement.remove();
1386
+ sidePanelElement = null;
1387
+ }
1388
+ sidePanelElement = createSidePanel();
1389
+ updateSidePanel();
1390
+ return true;
1391
+ }
1392
+
1393
+ // ===== Plugin Export =====
1394
+
1395
+ export const SpellCheckPlugin = (): Plugin => ({
1396
+ name: 'spellCheck',
1397
+
1398
+ init: () => {
1399
+ // Load custom dictionary from localStorage on plugin initialization
1400
+ loadCustomDictionary();
1401
+ attachToggleTriggerTracking();
1402
+ },
1403
+
1404
+ toolbar: [
1405
+ {
1406
+ label: 'Spell Check',
1407
+ command: 'toggleSpellCheck',
1408
+ icon: '<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M3 12.5L3.84375 9.5M3.84375 9.5L5 5.38889C5 5.38889 5.25 4.5 6 4.5C6.75 4.5 7 5.38889 7 5.38889L8.15625 9.5M3.84375 9.5H8.15625M9 12.5L8.15625 9.5M13 16.8333L15.4615 19.5L21 13.5M12 8.5H15C16.1046 8.5 17 7.60457 17 6.5C17 5.39543 16.1046 4.5 15 4.5H12V8.5ZM12 8.5H16C17.1046 8.5 18 9.39543 18 10.5C18 11.6046 17.1046 12.5 16 12.5H12V8.5Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>',
1409
+ shortcut: 'F7'
1410
+ }
1411
+ ],
1412
+
1413
+ commands: {
1414
+ toggleSpellCheck: () => {
1415
+ toggleSpellCheck();
1416
+ return true;
1417
+ }
1418
+ },
1419
+
1420
+ keymap: {
1421
+ 'F7': 'toggleSpellCheck'
1422
+ }
1423
+ });