diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/modules/InlineSpellChecker.sys.mjs | 626 |
1 files changed, 626 insertions, 0 deletions
diff --git a/toolkit/modules/InlineSpellChecker.sys.mjs b/toolkit/modules/InlineSpellChecker.sys.mjs new file mode 100644 index 0000000000..7d70c6a89a --- /dev/null +++ b/toolkit/modules/InlineSpellChecker.sys.mjs @@ -0,0 +1,626 @@ +/* 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 <textarea> or editable <input> + // (that allows text entry of some kind, so not e.g. <input type=checkbox>) + EDITABLE: 0x1, + + // Set when over an <input> element of any type. + INPUT: 0x2, + + // Set when over any <textarea>. + TEXTAREA: 0x4, + + // Set when over any text-entry <input>. + TEXTINPUT: 0x8, + + // Set when over an <input> that can be used as a keyword field. + KEYWORD: 0x10, + + // Set when over an element that otherwise would not be considered + // "editable" but is because content editable is enabled for the document. + CONTENTEDITABLE: 0x20, + + // Set when over an <input type="number"> or other non-text field. + NUMERIC: 0x40, + + // Set when over an <input type="password"> field. + PASSWORD: 0x80, + + // Set when spellcheckable. Replaces `EDITABLE`/`CONTENTEDITABLE` combination + // specifically for spellcheck. + SPELLCHECKABLE: 0x100, + + isTargetAKeywordField(aNode, window) { + if (!window.HTMLInputElement.isInstance(aNode)) { + return false; + } + + var form = aNode.form; + if (!form || aNode.type == "password") { + return false; + } + + var method = form.method.toUpperCase(); + + // These are the following types of forms we can create keywords for: + // + // method encoding type can create keyword + // GET * YES + // * YES + // POST YES + // POST application/x-www-form-urlencoded YES + // POST text/plain NO (a little tricky to do) + // POST multipart/form-data NO + // POST everything else YES + return ( + method == "GET" || + method == "" || + (form.enctype != "text/plain" && form.enctype != "multipart/form-data") + ); + }, + + // Returns the computed style attribute for the given element. + getComputedStyle(aElem, aProp) { + return aElem.ownerGlobal.getComputedStyle(aElem).getPropertyValue(aProp); + }, + + isEditable(element, window) { + var flags = 0; + if (window.HTMLInputElement.isInstance(element)) { + flags |= this.INPUT; + if (element.mozIsTextField(false) || element.type == "number") { + flags |= this.TEXTINPUT; + if (!element.readOnly) { + flags |= this.EDITABLE; + } + + if (element.type == "number") { + flags |= this.NUMERIC; + } + + // Allow spellchecking UI on all text and search inputs. + if ( + !element.readOnly && + (element.type == "text" || element.type == "search") + ) { + flags |= this.SPELLCHECKABLE; + } + if (this.isTargetAKeywordField(element, window)) { + flags |= this.KEYWORD; + } + if (element.type == "password") { + flags |= this.PASSWORD; + } + } + } else if (window.HTMLTextAreaElement.isInstance(element)) { + flags |= this.TEXTINPUT | this.TEXTAREA; + if (!element.readOnly) { + flags |= this.SPELLCHECKABLE | this.EDITABLE; + } + } + + if (!(flags & this.SPELLCHECKABLE)) { + var win = element.ownerGlobal; + if (win) { + var isSpellcheckable = false; + try { + var editingSession = win.docShell.editingSession; + if ( + editingSession.windowIsEditable(win) && + this.getComputedStyle(element, "-moz-user-modify") == "read-write" + ) { + isSpellcheckable = true; + } + } catch (ex) { + // If someone built with composer disabled, we can't get an editing session. + } + + if (isSpellcheckable) { + flags |= this.CONTENTEDITABLE | this.SPELLCHECKABLE; + } + } + } + + return flags; + }, +}; + +function RemoteSpellChecker(aSpellInfo, aWindowGlobalParent) { + this._spellInfo = aSpellInfo; + this._suggestionGenerator = null; + this._actor = aWindowGlobalParent.getActor("InlineSpellChecker"); + this._actor.registerDestructionObserver(this); +} + +RemoteSpellChecker.prototype = { + get canSpellCheck() { + return this._spellInfo.canSpellCheck; + }, + get spellCheckPending() { + return this._spellInfo.initialSpellCheckPending; + }, + get overMisspelling() { + return this._spellInfo.overMisspelling; + }, + get enableRealTimeSpell() { + return this._spellInfo.enableRealTimeSpell; + }, + get suggestions() { + return this._spellInfo.spellSuggestions; + }, + + get currentDictionaries() { + return this._spellInfo.currentDictionaries; + }, + set currentDictionaries(dicts) { + this._spellInfo.currentDictionaries = dicts; + }, + get dictionaryList() { + return this._spellInfo.dictionaryList.slice(); + }, + + selectDictionaries(localeCodes) { + this._actor.selectDictionaries({ localeCodes }); + }, + + replaceMisspelling(suggestion) { + this._actor.replaceMisspelling({ suggestion }); + }, + + toggleEnabled() { + this._actor.toggleEnabled(); + }, + addToDictionary() { + // This is really ugly. There is an nsISpellChecker somewhere in the + // parent that corresponds to our current element's spell checker in the + // child, but it's hard to access it. However, we know that + // addToDictionary adds the word to the singleton personal dictionary, so + // we just do that here. + // NB: We also rely on the fact that we only ever pass an empty string in + // as the "lang". + + let dictionary = Cc[ + "@mozilla.org/spellchecker/personaldictionary;1" + ].getService(Ci.mozIPersonalDictionary); + dictionary.addWord(this._spellInfo.misspelling); + this._actor.recheckSpelling(); + }, + undoAddToDictionary(word) { + let dictionary = Cc[ + "@mozilla.org/spellchecker/personaldictionary;1" + ].getService(Ci.mozIPersonalDictionary); + dictionary.removeWord(word); + this._actor.recheckSpelling(); + }, + ignoreWord() { + let dictionary = Cc[ + "@mozilla.org/spellchecker/personaldictionary;1" + ].getService(Ci.mozIPersonalDictionary); + dictionary.ignoreWord(this._spellInfo.misspelling); + this._actor.recheckSpelling(); + }, + uninit() { + if (this._actor) { + this._actor.uninit(); + this._actor.unregisterDestructionObserver(this); + } + }, + + actorDestroyed() { + // The actor lets us know if it gets destroyed, so we don't + // later try to call `.uninit()` on it. + this._actor = null; + }, +}; |