/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const MAX_UNDO_STACK_DEPTH = 1; export function InlineSpellChecker(aEditor) { this.init(aEditor); this.mAddedWordStack = []; // We init this here to preserve it between init/uninit calls } InlineSpellChecker.prototype = { // Call this function to initialize for a given editor init(aEditor) { this.uninit(); this.mEditor = aEditor; try { this.mInlineSpellChecker = this.mEditor.getInlineSpellChecker(true); // note: this might have been NULL if there is no chance we can spellcheck } catch (e) { this.mInlineSpellChecker = null; } }, initFromRemote(aSpellInfo, aWindowGlobalParent) { if (this.mRemote) { // We shouldn't get here, but let's just recover instead of bricking the // menu by throwing exceptions: console.error(new Error("Unexpected remote spellchecker present!")); try { this.mRemote.uninit(); } catch (ex) { console.error(ex); } this.mRemote = null; } this.uninit(); if (!aSpellInfo) { return; } this.mInlineSpellChecker = this.mRemote = new RemoteSpellChecker( aSpellInfo, aWindowGlobalParent ); this.mOverMisspelling = aSpellInfo.overMisspelling; this.mMisspelling = aSpellInfo.misspelling; }, // call this to clear state uninit() { if (this.mRemote) { this.mRemote.uninit(); this.mRemote = null; } this.mEditor = null; this.mInlineSpellChecker = null; this.mOverMisspelling = false; this.mMisspelling = ""; this.mMenu = null; this.mSuggestionItems = []; this.mDictionaryMenu = null; this.mDictionaryItems = []; this.mWordNode = null; }, // for each UI event, you must call this function, it will compute the // word the cursor is over initFromEvent(rangeParent, rangeOffset) { this.mOverMisspelling = false; if (!rangeParent || !this.mInlineSpellChecker) { return; } var selcon = this.mEditor.selectionController; var spellsel = selcon.getSelection(selcon.SELECTION_SPELLCHECK); if (spellsel.rangeCount == 0) { return; } // easy case - no misspellings var range = this.mInlineSpellChecker.getMisspelledWord( rangeParent, rangeOffset ); if (!range) { return; } // not over a misspelled word this.mMisspelling = range.toString(); this.mOverMisspelling = true; this.mWordNode = rangeParent; this.mWordOffset = rangeOffset; }, // returns false if there should be no spellchecking UI enabled at all, true // means that you can at least give the user the ability to turn it on. get canSpellCheck() { // inline spell checker objects will be created only if there are actual // dictionaries available if (this.mRemote) { return this.mRemote.canSpellCheck; } return this.mInlineSpellChecker != null; }, get initialSpellCheckPending() { if (this.mRemote) { return this.mRemote.spellCheckPending; } return !!( this.mInlineSpellChecker && !this.mInlineSpellChecker.spellChecker && this.mInlineSpellChecker.spellCheckPending ); }, // Whether spellchecking is enabled in the current box get enabled() { if (this.mRemote) { return this.mRemote.enableRealTimeSpell; } return ( this.mInlineSpellChecker && this.mInlineSpellChecker.enableRealTimeSpell ); }, set enabled(isEnabled) { if (this.mRemote) { this.mRemote.setSpellcheckUserOverride(isEnabled); } else if (this.mInlineSpellChecker) { this.mEditor.setSpellcheckUserOverride(isEnabled); } }, // returns true if the given event is over a misspelled word get overMisspelling() { return this.mOverMisspelling; }, // this prepends up to "maxNumber" suggestions at the given menu position // for the word under the cursor. Returns the number of suggestions inserted. addSuggestionsToMenuOnParent(menu, insertBefore, maxNumber) { if (this.mRemote) { // This is used on parent process only. // If you want to add suggestions to context menu, get suggestions then // use addSuggestionsToMenu instead. return 0; } if (!this.mInlineSpellChecker || !this.mOverMisspelling) { return 0; } let spellchecker = this.mInlineSpellChecker.spellChecker; let spellSuggestions = []; try { if (!spellchecker.CheckCurrentWord(this.mMisspelling)) { return 0; } for (let i = 0; i < maxNumber; i++) { let suggestion = spellchecker.GetSuggestedWord(); if (!suggestion.length) { // no more data break; } spellSuggestions.push(suggestion); } } catch (e) { return 0; } return this._addSuggestionsToMenu(menu, insertBefore, spellSuggestions); }, addSuggestionsToMenu(menu, insertBefore, spellSuggestions) { if ( !this.mRemote && (!this.mInlineSpellChecker || !this.mOverMisspelling) ) { return 0; } // nothing to do if (!spellSuggestions?.length) { return 0; } return this._addSuggestionsToMenu(menu, insertBefore, spellSuggestions); }, _addSuggestionsToMenu(menu, insertBefore, spellSuggestions) { this.mMenu = menu; this.mSuggestionItems = []; for (let suggestion of spellSuggestions) { var item = menu.ownerDocument.createXULElement("menuitem"); this.mSuggestionItems.push(item); item.setAttribute("label", suggestion); item.setAttribute("value", suggestion); item.addEventListener( "command", this.replaceMisspelling.bind(this, suggestion), true ); item.setAttribute("class", "spell-suggestion"); menu.insertBefore(item, insertBefore); } return spellSuggestions.length; }, // undoes the work of addSuggestionsToMenu for the same menu // (call from popup hiding) clearSuggestionsFromMenu() { for (var i = 0; i < this.mSuggestionItems.length; i++) { this.mMenu.removeChild(this.mSuggestionItems[i]); } this.mSuggestionItems = []; }, sortDictionaryList(list) { var sortedList = []; var names = Services.intl.getLocaleDisplayNames(undefined, list); for (var i = 0; i < list.length; i++) { sortedList.push({ localeCode: list[i], displayName: names[i] }); } let comparer = new Services.intl.Collator().compare; sortedList.sort((a, b) => comparer(a.displayName, b.displayName)); return sortedList; }, async languageMenuListener(evt) { let curlangs = new Set(); if (this.mRemote) { curlangs = new Set(this.mRemote.currentDictionaries); } else if (this.mInlineSpellChecker) { let spellchecker = this.mInlineSpellChecker.spellChecker; try { curlangs = new Set(spellchecker.getCurrentDictionaries()); } catch (e) {} } let localeCodes = new Set(curlangs); let localeCode = evt.target.dataset.localeCode; if (localeCodes.has(localeCode)) { localeCodes.delete(localeCode); } else { localeCodes.add(localeCode); } let dictionaries = Array.from(localeCodes); await this.selectDictionaries(dictionaries); if (this.mRemote) { // Store the new set in case the menu doesn't close. this.mRemote.currentDictionaries = dictionaries; } // Notify change of dictionary, especially for Thunderbird, // which is otherwise not notified any more. let view = this.mDictionaryMenu.ownerGlobal; let spellcheckChangeEvent = new view.CustomEvent("spellcheck-changed", { detail: { dictionaries }, }); this.mDictionaryMenu.ownerDocument.dispatchEvent(spellcheckChangeEvent); }, // returns the number of dictionary languages. If insertBefore is NULL, this // does an append to the given menu addDictionaryListToMenu(menu, insertBefore) { this.mDictionaryMenu = menu; this.mDictionaryItems = []; if (!this.enabled) { return 0; } let list; let curlangs = new Set(); if (this.mRemote) { list = this.mRemote.dictionaryList; curlangs = new Set(this.mRemote.currentDictionaries); } else if (this.mInlineSpellChecker) { let spellchecker = this.mInlineSpellChecker.spellChecker; list = spellchecker.GetDictionaryList(); try { curlangs = new Set(spellchecker.getCurrentDictionaries()); } catch (e) {} } let sortedList = this.sortDictionaryList(list); this.languageMenuListenerBind = this.languageMenuListener.bind(this); menu.addEventListener("command", this.languageMenuListenerBind, true); for (let i = 0; i < sortedList.length; i++) { let item = menu.ownerDocument.createXULElement("menuitem"); item.setAttribute( "id", "spell-check-dictionary-" + sortedList[i].localeCode ); // XXX: Once Fluent has dynamic references, we could also lazily // inject regionNames/languageNames FTL and localize using // `l10n-id` here. item.setAttribute("label", sortedList[i].displayName); item.setAttribute("type", "checkbox"); item.setAttribute("selection-type", "multiple"); if (sortedList.length > 1) { item.setAttribute("closemenu", "none"); } this.mDictionaryItems.push(item); item.dataset.localeCode = sortedList[i].localeCode; if (curlangs.has(sortedList[i].localeCode)) { item.setAttribute("checked", "true"); } if (insertBefore) { menu.insertBefore(item, insertBefore); } else { menu.appendChild(item); } } return list.length; }, // undoes the work of addDictionaryListToMenu for the menu // (call on popup hiding) clearDictionaryListFromMenu() { this.mDictionaryMenu?.removeEventListener( "command", this.languageMenuListenerBind, true ); for (var i = 0; i < this.mDictionaryItems.length; i++) { this.mDictionaryMenu.removeChild(this.mDictionaryItems[i]); } this.mDictionaryItems = []; }, // callback for selecting a dictionary async selectDictionaries(localeCodes) { if (this.mRemote) { this.mRemote.selectDictionaries(localeCodes); return; } if (!this.mInlineSpellChecker) { return; } var spellchecker = this.mInlineSpellChecker.spellChecker; await spellchecker.setCurrentDictionaries(localeCodes); this.mInlineSpellChecker.spellCheckRange(null); // causes recheck }, // callback for selecting a suggested replacement replaceMisspelling(suggestion) { if (this.mRemote) { this.mRemote.replaceMisspelling(suggestion); return; } if (!this.mInlineSpellChecker || !this.mOverMisspelling) { return; } this.mInlineSpellChecker.replaceWord( this.mWordNode, this.mWordOffset, suggestion ); }, // callback for enabling or disabling spellchecking toggleEnabled() { if (this.mRemote) { this.mRemote.toggleEnabled(); } else { this.mEditor.setSpellcheckUserOverride( !this.mInlineSpellChecker.enableRealTimeSpell ); } }, // callback for adding the current misspelling to the user-defined dictionary addToDictionary() { // Prevent the undo stack from growing over the max depth if (this.mAddedWordStack.length == MAX_UNDO_STACK_DEPTH) { this.mAddedWordStack.shift(); } this.mAddedWordStack.push(this.mMisspelling); if (this.mRemote) { this.mRemote.addToDictionary(); } else { this.mInlineSpellChecker.addWordToDictionary(this.mMisspelling); } }, // callback for removing the last added word to the dictionary LIFO fashion undoAddToDictionary() { if (this.mAddedWordStack.length) { var word = this.mAddedWordStack.pop(); if (this.mRemote) { this.mRemote.undoAddToDictionary(word); } else { this.mInlineSpellChecker.removeWordFromDictionary(word); } } }, canUndo() { // Return true if we have words on the stack return !!this.mAddedWordStack.length; }, ignoreWord() { if (this.mRemote) { this.mRemote.ignoreWord(); } else { this.mInlineSpellChecker.ignoreWord(this.mMisspelling); } }, }; export var SpellCheckHelper = { // Set when over a non-read-only