@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.
- package/dist/index.cjs.js +66 -0
- package/dist/index.esm.js +458 -0
- package/package.json +35 -0
- package/src/SpellCheckPlugin.native.ts +1423 -0
- package/src/index.ts +1 -0
|
@@ -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
|
+
});
|