1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 const MAX_UNDO_STACK_DEPTH = 1;
7 export function InlineSpellChecker(aEditor) {
9 this.mAddedWordStack = []; // We init this here to preserve it between init/uninit calls
12 InlineSpellChecker.prototype = {
13 // Call this function to initialize for a given editor
16 this.mEditor = aEditor;
18 this.mInlineSpellChecker = this.mEditor.getInlineSpellChecker(true);
19 // note: this might have been NULL if there is no chance we can spellcheck
21 this.mInlineSpellChecker = null;
25 initFromRemote(aSpellInfo, aWindowGlobalParent) {
27 // We shouldn't get here, but let's just recover instead of bricking the
28 // menu by throwing exceptions:
29 console.error(new Error("Unexpected remote spellchecker present!"));
31 this.mRemote.uninit();
42 this.mInlineSpellChecker = this.mRemote = new RemoteSpellChecker(
46 this.mOverMisspelling = aSpellInfo.overMisspelling;
47 this.mMisspelling = aSpellInfo.misspelling;
50 // call this to clear state
53 this.mRemote.uninit();
58 this.mInlineSpellChecker = null;
59 this.mOverMisspelling = false;
60 this.mMisspelling = "";
62 this.mSuggestionItems = [];
63 this.mDictionaryMenu = null;
64 this.mDictionaryItems = [];
65 this.mWordNode = null;
68 // for each UI event, you must call this function, it will compute the
69 // word the cursor is over
70 initFromEvent(rangeParent, rangeOffset) {
71 this.mOverMisspelling = false;
73 if (!rangeParent || !this.mInlineSpellChecker) {
77 var selcon = this.mEditor.selectionController;
78 var spellsel = selcon.getSelection(selcon.SELECTION_SPELLCHECK);
79 if (spellsel.rangeCount == 0) {
81 } // easy case - no misspellings
83 var range = this.mInlineSpellChecker.getMisspelledWord(
89 } // not over a misspelled word
91 this.mMisspelling = range.toString();
92 this.mOverMisspelling = true;
93 this.mWordNode = rangeParent;
94 this.mWordOffset = rangeOffset;
97 // returns false if there should be no spellchecking UI enabled at all, true
98 // means that you can at least give the user the ability to turn it on.
100 // inline spell checker objects will be created only if there are actual
101 // dictionaries available
103 return this.mRemote.canSpellCheck;
105 return this.mInlineSpellChecker != null;
108 get initialSpellCheckPending() {
110 return this.mRemote.spellCheckPending;
113 this.mInlineSpellChecker &&
114 !this.mInlineSpellChecker.spellChecker &&
115 this.mInlineSpellChecker.spellCheckPending
119 // Whether spellchecking is enabled in the current box
122 return this.mRemote.enableRealTimeSpell;
125 this.mInlineSpellChecker && this.mInlineSpellChecker.enableRealTimeSpell
128 set enabled(isEnabled) {
130 this.mRemote.setSpellcheckUserOverride(isEnabled);
131 } else if (this.mInlineSpellChecker) {
132 this.mEditor.setSpellcheckUserOverride(isEnabled);
136 // returns true if the given event is over a misspelled word
137 get overMisspelling() {
138 return this.mOverMisspelling;
141 // this prepends up to "maxNumber" suggestions at the given menu position
142 // for the word under the cursor. Returns the number of suggestions inserted.
143 addSuggestionsToMenuOnParent(menu, insertBefore, maxNumber) {
145 // This is used on parent process only.
146 // If you want to add suggestions to context menu, get suggestions then
147 // use addSuggestionsToMenu instead.
150 if (!this.mInlineSpellChecker || !this.mOverMisspelling) {
154 let spellchecker = this.mInlineSpellChecker.spellChecker;
155 let spellSuggestions = [];
158 if (!spellchecker.CheckCurrentWord(this.mMisspelling)) {
162 for (let i = 0; i < maxNumber; i++) {
163 let suggestion = spellchecker.GetSuggestedWord();
164 if (!suggestion.length) {
168 spellSuggestions.push(suggestion);
173 return this._addSuggestionsToMenu(menu, insertBefore, spellSuggestions);
176 addSuggestionsToMenu(menu, insertBefore, spellSuggestions) {
179 (!this.mInlineSpellChecker || !this.mOverMisspelling)
184 if (!spellSuggestions?.length) {
188 return this._addSuggestionsToMenu(menu, insertBefore, spellSuggestions);
191 _addSuggestionsToMenu(menu, insertBefore, spellSuggestions) {
193 this.mSuggestionItems = [];
195 for (let suggestion of spellSuggestions) {
196 var item = menu.ownerDocument.createXULElement("menuitem");
197 this.mSuggestionItems.push(item);
198 item.setAttribute("label", suggestion);
199 item.setAttribute("value", suggestion);
200 item.addEventListener(
202 this.replaceMisspelling.bind(this, suggestion),
205 item.setAttribute("class", "spell-suggestion");
206 menu.insertBefore(item, insertBefore);
208 return spellSuggestions.length;
211 // undoes the work of addSuggestionsToMenu for the same menu
212 // (call from popup hiding)
213 clearSuggestionsFromMenu() {
214 for (var i = 0; i < this.mSuggestionItems.length; i++) {
215 this.mMenu.removeChild(this.mSuggestionItems[i]);
217 this.mSuggestionItems = [];
220 sortDictionaryList(list) {
222 var names = Services.intl.getLocaleDisplayNames(undefined, list);
223 for (var i = 0; i < list.length; i++) {
224 sortedList.push({ localeCode: list[i], displayName: names[i] });
226 let comparer = new Services.intl.Collator().compare;
227 sortedList.sort((a, b) => comparer(a.displayName, b.displayName));
231 async languageMenuListener(evt) {
232 let curlangs = new Set();
234 curlangs = new Set(this.mRemote.currentDictionaries);
235 } else if (this.mInlineSpellChecker) {
236 let spellchecker = this.mInlineSpellChecker.spellChecker;
238 curlangs = new Set(spellchecker.getCurrentDictionaries());
242 let localeCodes = new Set(curlangs);
243 let localeCode = evt.target.dataset.localeCode;
244 if (localeCodes.has(localeCode)) {
245 localeCodes.delete(localeCode);
247 localeCodes.add(localeCode);
249 let dictionaries = Array.from(localeCodes);
250 await this.selectDictionaries(dictionaries);
252 // Store the new set in case the menu doesn't close.
253 this.mRemote.currentDictionaries = dictionaries;
255 // Notify change of dictionary, especially for Thunderbird,
256 // which is otherwise not notified any more.
257 let view = this.mDictionaryMenu.ownerGlobal;
258 let spellcheckChangeEvent = new view.CustomEvent("spellcheck-changed", {
259 detail: { dictionaries },
261 this.mDictionaryMenu.ownerDocument.dispatchEvent(spellcheckChangeEvent);
264 // returns the number of dictionary languages. If insertBefore is NULL, this
265 // does an append to the given menu
266 addDictionaryListToMenu(menu, insertBefore) {
267 this.mDictionaryMenu = menu;
268 this.mDictionaryItems = [];
275 let curlangs = new Set();
277 list = this.mRemote.dictionaryList;
278 curlangs = new Set(this.mRemote.currentDictionaries);
279 } else if (this.mInlineSpellChecker) {
280 let spellchecker = this.mInlineSpellChecker.spellChecker;
281 list = spellchecker.GetDictionaryList();
283 curlangs = new Set(spellchecker.getCurrentDictionaries());
287 let sortedList = this.sortDictionaryList(list);
288 this.languageMenuListenerBind = this.languageMenuListener.bind(this);
289 menu.addEventListener("command", this.languageMenuListenerBind, true);
291 for (let i = 0; i < sortedList.length; i++) {
292 let item = menu.ownerDocument.createXULElement("menuitem");
296 "spell-check-dictionary-" + sortedList[i].localeCode
298 // XXX: Once Fluent has dynamic references, we could also lazily
299 // inject regionNames/languageNames FTL and localize using
301 item.setAttribute("label", sortedList[i].displayName);
302 item.setAttribute("type", "checkbox");
303 item.setAttribute("selection-type", "multiple");
304 if (sortedList.length > 1) {
305 item.setAttribute("closemenu", "none");
307 this.mDictionaryItems.push(item);
308 item.dataset.localeCode = sortedList[i].localeCode;
309 if (curlangs.has(sortedList[i].localeCode)) {
310 item.setAttribute("checked", "true");
313 menu.insertBefore(item, insertBefore);
315 menu.appendChild(item);
321 // undoes the work of addDictionaryListToMenu for the menu
322 // (call on popup hiding)
323 clearDictionaryListFromMenu() {
324 this.mDictionaryMenu?.removeEventListener(
326 this.languageMenuListenerBind,
329 for (var i = 0; i < this.mDictionaryItems.length; i++) {
330 this.mDictionaryMenu.removeChild(this.mDictionaryItems[i]);
332 this.mDictionaryItems = [];
335 // callback for selecting a dictionary
336 async selectDictionaries(localeCodes) {
338 this.mRemote.selectDictionaries(localeCodes);
341 if (!this.mInlineSpellChecker) {
344 var spellchecker = this.mInlineSpellChecker.spellChecker;
345 await spellchecker.setCurrentDictionaries(localeCodes);
346 this.mInlineSpellChecker.spellCheckRange(null); // causes recheck
349 // callback for selecting a suggested replacement
350 replaceMisspelling(suggestion) {
352 this.mRemote.replaceMisspelling(suggestion);
355 if (!this.mInlineSpellChecker || !this.mOverMisspelling) {
358 this.mInlineSpellChecker.replaceWord(
365 // callback for enabling or disabling spellchecking
368 this.mRemote.toggleEnabled();
370 this.mEditor.setSpellcheckUserOverride(
371 !this.mInlineSpellChecker.enableRealTimeSpell
376 // callback for adding the current misspelling to the user-defined dictionary
378 // Prevent the undo stack from growing over the max depth
379 if (this.mAddedWordStack.length == MAX_UNDO_STACK_DEPTH) {
380 this.mAddedWordStack.shift();
383 this.mAddedWordStack.push(this.mMisspelling);
385 this.mRemote.addToDictionary();
387 this.mInlineSpellChecker.addWordToDictionary(this.mMisspelling);
390 // callback for removing the last added word to the dictionary LIFO fashion
391 undoAddToDictionary() {
392 if (this.mAddedWordStack.length) {
393 var word = this.mAddedWordStack.pop();
395 this.mRemote.undoAddToDictionary(word);
397 this.mInlineSpellChecker.removeWordFromDictionary(word);
402 // Return true if we have words on the stack
403 return !!this.mAddedWordStack.length;
407 this.mRemote.ignoreWord();
409 this.mInlineSpellChecker.ignoreWord(this.mMisspelling);
414 export var SpellCheckHelper = {
415 // Set when over a non-read-only <textarea> or editable <input>
416 // (that allows text entry of some kind, so not e.g. <input type=checkbox>)
419 // Set when over an <input> element of any type.
422 // Set when over any <textarea>.
425 // Set when over any text-entry <input>.
428 // Set when over an <input> that can be used as a keyword field.
431 // Set when over an element that otherwise would not be considered
432 // "editable" but is because content editable is enabled for the document.
433 CONTENTEDITABLE: 0x20,
435 // Set when over an <input type="number"> or other non-text field.
438 // Set when over an <input type="password"> field.
441 // Set when spellcheckable. Replaces `EDITABLE`/`CONTENTEDITABLE` combination
442 // specifically for spellcheck.
443 SPELLCHECKABLE: 0x100,
445 isTargetAKeywordField(aNode, window) {
446 if (!window.HTMLInputElement.isInstance(aNode)) {
450 var form = aNode.form;
451 if (!form || aNode.type == "password") {
455 var method = form.method.toUpperCase();
457 // These are the following types of forms we can create keywords for:
459 // method encoding type can create keyword
463 // POST application/x-www-form-urlencoded YES
464 // POST text/plain NO (a little tricky to do)
465 // POST multipart/form-data NO
466 // POST everything else YES
470 (form.enctype != "text/plain" && form.enctype != "multipart/form-data")
474 // Returns the computed style attribute for the given element.
475 getComputedStyle(aElem, aProp) {
476 return aElem.ownerGlobal.getComputedStyle(aElem).getPropertyValue(aProp);
479 isEditable(element, window) {
481 if (window.HTMLInputElement.isInstance(element)) {
483 if (element.mozIsTextField(false) || element.type == "number") {
484 flags |= this.TEXTINPUT;
485 if (!element.readOnly) {
486 flags |= this.EDITABLE;
489 if (element.type == "number") {
490 flags |= this.NUMERIC;
493 // Allow spellchecking UI on all text and search inputs.
496 (element.type == "text" || element.type == "search")
498 flags |= this.SPELLCHECKABLE;
500 if (this.isTargetAKeywordField(element, window)) {
501 flags |= this.KEYWORD;
503 if (element.type == "password") {
504 flags |= this.PASSWORD;
507 } else if (window.HTMLTextAreaElement.isInstance(element)) {
508 flags |= this.TEXTINPUT | this.TEXTAREA;
509 if (!element.readOnly) {
510 flags |= this.SPELLCHECKABLE | this.EDITABLE;
514 if (!(flags & this.SPELLCHECKABLE)) {
515 var win = element.ownerGlobal;
517 var isSpellcheckable = false;
519 var editingSession = win.docShell.editingSession;
521 editingSession.windowIsEditable(win) &&
522 this.getComputedStyle(element, "-moz-user-modify") == "read-write"
524 isSpellcheckable = true;
527 // If someone built with composer disabled, we can't get an editing session.
530 if (isSpellcheckable) {
531 flags |= this.CONTENTEDITABLE | this.SPELLCHECKABLE;
540 function RemoteSpellChecker(aSpellInfo, aWindowGlobalParent) {
541 this._spellInfo = aSpellInfo;
542 this._suggestionGenerator = null;
543 this._actor = aWindowGlobalParent.getActor("InlineSpellChecker");
544 this._actor.registerDestructionObserver(this);
547 RemoteSpellChecker.prototype = {
548 get canSpellCheck() {
549 return this._spellInfo.canSpellCheck;
551 get spellCheckPending() {
552 return this._spellInfo.initialSpellCheckPending;
554 get overMisspelling() {
555 return this._spellInfo.overMisspelling;
557 get enableRealTimeSpell() {
558 return this._spellInfo.enableRealTimeSpell;
561 return this._spellInfo.spellSuggestions;
564 get currentDictionaries() {
565 return this._spellInfo.currentDictionaries;
567 set currentDictionaries(dicts) {
568 this._spellInfo.currentDictionaries = dicts;
570 get dictionaryList() {
571 return this._spellInfo.dictionaryList.slice();
574 selectDictionaries(localeCodes) {
575 this._actor.selectDictionaries({ localeCodes });
578 replaceMisspelling(suggestion) {
579 this._actor.replaceMisspelling({ suggestion });
583 this._actor.toggleEnabled();
586 // This is really ugly. There is an nsISpellChecker somewhere in the
587 // parent that corresponds to our current element's spell checker in the
588 // child, but it's hard to access it. However, we know that
589 // addToDictionary adds the word to the singleton personal dictionary, so
590 // we just do that here.
591 // NB: We also rely on the fact that we only ever pass an empty string in
595 "@mozilla.org/spellchecker/personaldictionary;1"
596 ].getService(Ci.mozIPersonalDictionary);
597 dictionary.addWord(this._spellInfo.misspelling);
598 this._actor.recheckSpelling();
600 undoAddToDictionary(word) {
602 "@mozilla.org/spellchecker/personaldictionary;1"
603 ].getService(Ci.mozIPersonalDictionary);
604 dictionary.removeWord(word);
605 this._actor.recheckSpelling();
609 "@mozilla.org/spellchecker/personaldictionary;1"
610 ].getService(Ci.mozIPersonalDictionary);
611 dictionary.ignoreWord(this._spellInfo.misspelling);
612 this._actor.recheckSpelling();
616 this._actor.uninit();
617 this._actor.unregisterDestructionObserver(this);
622 // The actor lets us know if it gets destroyed, so we don't
623 // later try to call `.uninit()` on it.