no bug - Bumping Firefox l10n changesets r=release a=l10n-bump DONTBUILD CLOSED TREE
[gecko.git] / toolkit / modules / InlineSpellChecker.sys.mjs
blob7d70c6a89a74a735ded03e4bc4f9dd7dbe95369a
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) {
8   this.init(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
14   init(aEditor) {
15     this.uninit();
16     this.mEditor = aEditor;
17     try {
18       this.mInlineSpellChecker = this.mEditor.getInlineSpellChecker(true);
19       // note: this might have been NULL if there is no chance we can spellcheck
20     } catch (e) {
21       this.mInlineSpellChecker = null;
22     }
23   },
25   initFromRemote(aSpellInfo, aWindowGlobalParent) {
26     if (this.mRemote) {
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!"));
30       try {
31         this.mRemote.uninit();
32       } catch (ex) {
33         console.error(ex);
34       }
35       this.mRemote = null;
36     }
37     this.uninit();
39     if (!aSpellInfo) {
40       return;
41     }
42     this.mInlineSpellChecker = this.mRemote = new RemoteSpellChecker(
43       aSpellInfo,
44       aWindowGlobalParent
45     );
46     this.mOverMisspelling = aSpellInfo.overMisspelling;
47     this.mMisspelling = aSpellInfo.misspelling;
48   },
50   // call this to clear state
51   uninit() {
52     if (this.mRemote) {
53       this.mRemote.uninit();
54       this.mRemote = null;
55     }
57     this.mEditor = null;
58     this.mInlineSpellChecker = null;
59     this.mOverMisspelling = false;
60     this.mMisspelling = "";
61     this.mMenu = null;
62     this.mSuggestionItems = [];
63     this.mDictionaryMenu = null;
64     this.mDictionaryItems = [];
65     this.mWordNode = null;
66   },
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) {
74       return;
75     }
77     var selcon = this.mEditor.selectionController;
78     var spellsel = selcon.getSelection(selcon.SELECTION_SPELLCHECK);
79     if (spellsel.rangeCount == 0) {
80       return;
81     } // easy case - no misspellings
83     var range = this.mInlineSpellChecker.getMisspelledWord(
84       rangeParent,
85       rangeOffset
86     );
87     if (!range) {
88       return;
89     } // not over a misspelled word
91     this.mMisspelling = range.toString();
92     this.mOverMisspelling = true;
93     this.mWordNode = rangeParent;
94     this.mWordOffset = rangeOffset;
95   },
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.
99   get canSpellCheck() {
100     // inline spell checker objects will be created only if there are actual
101     // dictionaries available
102     if (this.mRemote) {
103       return this.mRemote.canSpellCheck;
104     }
105     return this.mInlineSpellChecker != null;
106   },
108   get initialSpellCheckPending() {
109     if (this.mRemote) {
110       return this.mRemote.spellCheckPending;
111     }
112     return !!(
113       this.mInlineSpellChecker &&
114       !this.mInlineSpellChecker.spellChecker &&
115       this.mInlineSpellChecker.spellCheckPending
116     );
117   },
119   // Whether spellchecking is enabled in the current box
120   get enabled() {
121     if (this.mRemote) {
122       return this.mRemote.enableRealTimeSpell;
123     }
124     return (
125       this.mInlineSpellChecker && this.mInlineSpellChecker.enableRealTimeSpell
126     );
127   },
128   set enabled(isEnabled) {
129     if (this.mRemote) {
130       this.mRemote.setSpellcheckUserOverride(isEnabled);
131     } else if (this.mInlineSpellChecker) {
132       this.mEditor.setSpellcheckUserOverride(isEnabled);
133     }
134   },
136   // returns true if the given event is over a misspelled word
137   get overMisspelling() {
138     return this.mOverMisspelling;
139   },
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) {
144     if (this.mRemote) {
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.
148       return 0;
149     }
150     if (!this.mInlineSpellChecker || !this.mOverMisspelling) {
151       return 0;
152     }
154     let spellchecker = this.mInlineSpellChecker.spellChecker;
155     let spellSuggestions = [];
157     try {
158       if (!spellchecker.CheckCurrentWord(this.mMisspelling)) {
159         return 0;
160       }
162       for (let i = 0; i < maxNumber; i++) {
163         let suggestion = spellchecker.GetSuggestedWord();
164         if (!suggestion.length) {
165           // no more data
166           break;
167         }
168         spellSuggestions.push(suggestion);
169       }
170     } catch (e) {
171       return 0;
172     }
173     return this._addSuggestionsToMenu(menu, insertBefore, spellSuggestions);
174   },
176   addSuggestionsToMenu(menu, insertBefore, spellSuggestions) {
177     if (
178       !this.mRemote &&
179       (!this.mInlineSpellChecker || !this.mOverMisspelling)
180     ) {
181       return 0;
182     } // nothing to do
184     if (!spellSuggestions?.length) {
185       return 0;
186     }
188     return this._addSuggestionsToMenu(menu, insertBefore, spellSuggestions);
189   },
191   _addSuggestionsToMenu(menu, insertBefore, spellSuggestions) {
192     this.mMenu = menu;
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(
201         "command",
202         this.replaceMisspelling.bind(this, suggestion),
203         true
204       );
205       item.setAttribute("class", "spell-suggestion");
206       menu.insertBefore(item, insertBefore);
207     }
208     return spellSuggestions.length;
209   },
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]);
216     }
217     this.mSuggestionItems = [];
218   },
220   sortDictionaryList(list) {
221     var sortedList = [];
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] });
225     }
226     let comparer = new Services.intl.Collator().compare;
227     sortedList.sort((a, b) => comparer(a.displayName, b.displayName));
228     return sortedList;
229   },
231   async languageMenuListener(evt) {
232     let curlangs = new Set();
233     if (this.mRemote) {
234       curlangs = new Set(this.mRemote.currentDictionaries);
235     } else if (this.mInlineSpellChecker) {
236       let spellchecker = this.mInlineSpellChecker.spellChecker;
237       try {
238         curlangs = new Set(spellchecker.getCurrentDictionaries());
239       } catch (e) {}
240     }
242     let localeCodes = new Set(curlangs);
243     let localeCode = evt.target.dataset.localeCode;
244     if (localeCodes.has(localeCode)) {
245       localeCodes.delete(localeCode);
246     } else {
247       localeCodes.add(localeCode);
248     }
249     let dictionaries = Array.from(localeCodes);
250     await this.selectDictionaries(dictionaries);
251     if (this.mRemote) {
252       // Store the new set in case the menu doesn't close.
253       this.mRemote.currentDictionaries = dictionaries;
254     }
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 },
260     });
261     this.mDictionaryMenu.ownerDocument.dispatchEvent(spellcheckChangeEvent);
262   },
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 = [];
270     if (!this.enabled) {
271       return 0;
272     }
274     let list;
275     let curlangs = new Set();
276     if (this.mRemote) {
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();
282       try {
283         curlangs = new Set(spellchecker.getCurrentDictionaries());
284       } catch (e) {}
285     }
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");
294       item.setAttribute(
295         "id",
296         "spell-check-dictionary-" + sortedList[i].localeCode
297       );
298       // XXX: Once Fluent has dynamic references, we could also lazily
299       //      inject regionNames/languageNames FTL and localize using
300       //      `l10n-id` here.
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");
306       }
307       this.mDictionaryItems.push(item);
308       item.dataset.localeCode = sortedList[i].localeCode;
309       if (curlangs.has(sortedList[i].localeCode)) {
310         item.setAttribute("checked", "true");
311       }
312       if (insertBefore) {
313         menu.insertBefore(item, insertBefore);
314       } else {
315         menu.appendChild(item);
316       }
317     }
318     return list.length;
319   },
321   // undoes the work of addDictionaryListToMenu for the menu
322   // (call on popup hiding)
323   clearDictionaryListFromMenu() {
324     this.mDictionaryMenu?.removeEventListener(
325       "command",
326       this.languageMenuListenerBind,
327       true
328     );
329     for (var i = 0; i < this.mDictionaryItems.length; i++) {
330       this.mDictionaryMenu.removeChild(this.mDictionaryItems[i]);
331     }
332     this.mDictionaryItems = [];
333   },
335   // callback for selecting a dictionary
336   async selectDictionaries(localeCodes) {
337     if (this.mRemote) {
338       this.mRemote.selectDictionaries(localeCodes);
339       return;
340     }
341     if (!this.mInlineSpellChecker) {
342       return;
343     }
344     var spellchecker = this.mInlineSpellChecker.spellChecker;
345     await spellchecker.setCurrentDictionaries(localeCodes);
346     this.mInlineSpellChecker.spellCheckRange(null); // causes recheck
347   },
349   // callback for selecting a suggested replacement
350   replaceMisspelling(suggestion) {
351     if (this.mRemote) {
352       this.mRemote.replaceMisspelling(suggestion);
353       return;
354     }
355     if (!this.mInlineSpellChecker || !this.mOverMisspelling) {
356       return;
357     }
358     this.mInlineSpellChecker.replaceWord(
359       this.mWordNode,
360       this.mWordOffset,
361       suggestion
362     );
363   },
365   // callback for enabling or disabling spellchecking
366   toggleEnabled() {
367     if (this.mRemote) {
368       this.mRemote.toggleEnabled();
369     } else {
370       this.mEditor.setSpellcheckUserOverride(
371         !this.mInlineSpellChecker.enableRealTimeSpell
372       );
373     }
374   },
376   // callback for adding the current misspelling to the user-defined dictionary
377   addToDictionary() {
378     // Prevent the undo stack from growing over the max depth
379     if (this.mAddedWordStack.length == MAX_UNDO_STACK_DEPTH) {
380       this.mAddedWordStack.shift();
381     }
383     this.mAddedWordStack.push(this.mMisspelling);
384     if (this.mRemote) {
385       this.mRemote.addToDictionary();
386     } else {
387       this.mInlineSpellChecker.addWordToDictionary(this.mMisspelling);
388     }
389   },
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();
394       if (this.mRemote) {
395         this.mRemote.undoAddToDictionary(word);
396       } else {
397         this.mInlineSpellChecker.removeWordFromDictionary(word);
398       }
399     }
400   },
401   canUndo() {
402     // Return true if we have words on the stack
403     return !!this.mAddedWordStack.length;
404   },
405   ignoreWord() {
406     if (this.mRemote) {
407       this.mRemote.ignoreWord();
408     } else {
409       this.mInlineSpellChecker.ignoreWord(this.mMisspelling);
410     }
411   },
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>)
417   EDITABLE: 0x1,
419   // Set when over an <input> element of any type.
420   INPUT: 0x2,
422   // Set when over any <textarea>.
423   TEXTAREA: 0x4,
425   // Set when over any text-entry <input>.
426   TEXTINPUT: 0x8,
428   // Set when over an <input> that can be used as a keyword field.
429   KEYWORD: 0x10,
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.
436   NUMERIC: 0x40,
438   // Set when over an <input type="password"> field.
439   PASSWORD: 0x80,
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)) {
447       return false;
448     }
450     var form = aNode.form;
451     if (!form || aNode.type == "password") {
452       return false;
453     }
455     var method = form.method.toUpperCase();
457     // These are the following types of forms we can create keywords for:
458     //
459     // method   encoding type       can create keyword
460     // GET      *                                 YES
461     //          *                                 YES
462     // POST                                       YES
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
467     return (
468       method == "GET" ||
469       method == "" ||
470       (form.enctype != "text/plain" && form.enctype != "multipart/form-data")
471     );
472   },
474   // Returns the computed style attribute for the given element.
475   getComputedStyle(aElem, aProp) {
476     return aElem.ownerGlobal.getComputedStyle(aElem).getPropertyValue(aProp);
477   },
479   isEditable(element, window) {
480     var flags = 0;
481     if (window.HTMLInputElement.isInstance(element)) {
482       flags |= this.INPUT;
483       if (element.mozIsTextField(false) || element.type == "number") {
484         flags |= this.TEXTINPUT;
485         if (!element.readOnly) {
486           flags |= this.EDITABLE;
487         }
489         if (element.type == "number") {
490           flags |= this.NUMERIC;
491         }
493         // Allow spellchecking UI on all text and search inputs.
494         if (
495           !element.readOnly &&
496           (element.type == "text" || element.type == "search")
497         ) {
498           flags |= this.SPELLCHECKABLE;
499         }
500         if (this.isTargetAKeywordField(element, window)) {
501           flags |= this.KEYWORD;
502         }
503         if (element.type == "password") {
504           flags |= this.PASSWORD;
505         }
506       }
507     } else if (window.HTMLTextAreaElement.isInstance(element)) {
508       flags |= this.TEXTINPUT | this.TEXTAREA;
509       if (!element.readOnly) {
510         flags |= this.SPELLCHECKABLE | this.EDITABLE;
511       }
512     }
514     if (!(flags & this.SPELLCHECKABLE)) {
515       var win = element.ownerGlobal;
516       if (win) {
517         var isSpellcheckable = false;
518         try {
519           var editingSession = win.docShell.editingSession;
520           if (
521             editingSession.windowIsEditable(win) &&
522             this.getComputedStyle(element, "-moz-user-modify") == "read-write"
523           ) {
524             isSpellcheckable = true;
525           }
526         } catch (ex) {
527           // If someone built with composer disabled, we can't get an editing session.
528         }
530         if (isSpellcheckable) {
531           flags |= this.CONTENTEDITABLE | this.SPELLCHECKABLE;
532         }
533       }
534     }
536     return flags;
537   },
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;
550   },
551   get spellCheckPending() {
552     return this._spellInfo.initialSpellCheckPending;
553   },
554   get overMisspelling() {
555     return this._spellInfo.overMisspelling;
556   },
557   get enableRealTimeSpell() {
558     return this._spellInfo.enableRealTimeSpell;
559   },
560   get suggestions() {
561     return this._spellInfo.spellSuggestions;
562   },
564   get currentDictionaries() {
565     return this._spellInfo.currentDictionaries;
566   },
567   set currentDictionaries(dicts) {
568     this._spellInfo.currentDictionaries = dicts;
569   },
570   get dictionaryList() {
571     return this._spellInfo.dictionaryList.slice();
572   },
574   selectDictionaries(localeCodes) {
575     this._actor.selectDictionaries({ localeCodes });
576   },
578   replaceMisspelling(suggestion) {
579     this._actor.replaceMisspelling({ suggestion });
580   },
582   toggleEnabled() {
583     this._actor.toggleEnabled();
584   },
585   addToDictionary() {
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
592     // as the "lang".
594     let dictionary = Cc[
595       "@mozilla.org/spellchecker/personaldictionary;1"
596     ].getService(Ci.mozIPersonalDictionary);
597     dictionary.addWord(this._spellInfo.misspelling);
598     this._actor.recheckSpelling();
599   },
600   undoAddToDictionary(word) {
601     let dictionary = Cc[
602       "@mozilla.org/spellchecker/personaldictionary;1"
603     ].getService(Ci.mozIPersonalDictionary);
604     dictionary.removeWord(word);
605     this._actor.recheckSpelling();
606   },
607   ignoreWord() {
608     let dictionary = Cc[
609       "@mozilla.org/spellchecker/personaldictionary;1"
610     ].getService(Ci.mozIPersonalDictionary);
611     dictionary.ignoreWord(this._spellInfo.misspelling);
612     this._actor.recheckSpelling();
613   },
614   uninit() {
615     if (this._actor) {
616       this._actor.uninit();
617       this._actor.unregisterDestructionObserver(this);
618     }
619   },
621   actorDestroyed() {
622     // The actor lets us know if it gets destroyed, so we don't
623     // later try to call `.uninit()` on it.
624     this._actor = null;
625   },