diff options
Diffstat (limited to 'toolkit/modules/InlineSpellChecker.jsm')
-rw-r--r-- | toolkit/modules/InlineSpellChecker.jsm | 594 |
1 files changed, 594 insertions, 0 deletions
diff --git a/toolkit/modules/InlineSpellChecker.jsm b/toolkit/modules/InlineSpellChecker.jsm new file mode 100644 index 0000000000..ffc58714b0 --- /dev/null +++ b/toolkit/modules/InlineSpellChecker.jsm @@ -0,0 +1,594 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["InlineSpellChecker", "SpellCheckHelper"]; +const MAX_UNDO_STACK_DEPTH = 1; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +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: + Cu.reportError(new Error("Unexpected remote spellchecker present!")); + try { + this.mRemote.uninit(); + } catch (ex) { + Cu.reportError(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.mSpellSuggestions = []; + 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. + addSuggestionsToMenu(menu, insertBefore, maxNumber) { + if ( + !this.mRemote && + (!this.mInlineSpellChecker || !this.mOverMisspelling) + ) { + return 0; + } // nothing to do + + var spellchecker = this.mRemote || this.mInlineSpellChecker.spellChecker; + try { + if (!this.mRemote && !spellchecker.CheckCurrentWord(this.mMisspelling)) { + return 0; + } // word seems not misspelled after all (?) + } catch (e) { + return 0; + } + + this.mMenu = menu; + this.mSpellSuggestions = []; + this.mSuggestionItems = []; + for (var i = 0; i < maxNumber; i++) { + var suggestion = spellchecker.GetSuggestedWord(); + if (!suggestion.length) { + break; + } + this.mSpellSuggestions.push(suggestion); + + var item = menu.ownerDocument.createXULElement("menuitem"); + this.mSuggestionItems.push(item); + item.setAttribute("label", suggestion); + item.setAttribute("value", suggestion); + // this function thing is necessary to generate a callback with the + // correct binding of "val" (the index in this loop). + var callback = function(me, val) { + return function(evt) { + me.replaceMisspelling(val); + }; + }; + item.addEventListener("command", callback(this, i), true); + item.setAttribute("class", "spell-suggestion"); + menu.insertBefore(item, insertBefore); + } + return this.mSpellSuggestions.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; + }, + + // 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; + } + + var list; + var curlang = ""; + if (this.mRemote) { + list = this.mRemote.dictionaryList; + curlang = this.mRemote.currentDictionary; + } else if (this.mInlineSpellChecker) { + var spellchecker = this.mInlineSpellChecker.spellChecker; + list = spellchecker.GetDictionaryList(); + try { + curlang = spellchecker.GetCurrentDictionary(); + } catch (e) {} + } + + var sortedList = this.sortDictionaryList(list); + + for (var i = 0; i < sortedList.length; i++) { + var 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", "radio"); + this.mDictionaryItems.push(item); + if (curlang == sortedList[i].localeCode) { + item.setAttribute("checked", "true"); + } else { + var callback = function(me, localeCode) { + return function(evt) { + me.selectDictionary(localeCode); + // Notify change of dictionary, especially for Thunderbird, + // which is otherwise not notified any more. + var view = menu.ownerGlobal; + var spellcheckChangeEvent = new view.CustomEvent( + "spellcheck-changed", + { detail: { dictionary: localeCode } } + ); + menu.ownerDocument.dispatchEvent(spellcheckChangeEvent); + }; + }; + item.addEventListener( + "command", + callback(this, sortedList[i].localeCode), + 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() { + for (var i = 0; i < this.mDictionaryItems.length; i++) { + this.mDictionaryMenu.removeChild(this.mDictionaryItems[i]); + } + this.mDictionaryItems = []; + }, + + // callback for selecting a dictionary + selectDictionary(localeCode) { + if (this.mRemote) { + this.mRemote.selectDictionary(localeCode); + return; + } + if (!this.mInlineSpellChecker) { + return; + } + var spellchecker = this.mInlineSpellChecker.spellChecker; + spellchecker.SetCurrentDictionary(localeCode); + this.mInlineSpellChecker.spellCheckRange(null); // causes recheck + }, + + // callback for selecting a suggested replacement + replaceMisspelling(index) { + if (this.mRemote) { + this.mRemote.replaceMisspelling(index); + return; + } + if (!this.mInlineSpellChecker || !this.mOverMisspelling) { + return; + } + if (index < 0 || index >= this.mSpellSuggestions.length) { + return; + } + this.mInlineSpellChecker.replaceWord( + this.mWordNode, + this.mWordOffset, + this.mSpellSuggestions[index] + ); + }, + + // 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); + } + }, +}; + +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 (!(aNode instanceof window.HTMLInputElement)) { + 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 (element instanceof window.HTMLInputElement) { + 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 (element instanceof window.HTMLTextAreaElement) { + 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; + }, + + GetSuggestedWord() { + if (!this._suggestionGenerator) { + this._suggestionGenerator = (function*(spellInfo) { + for (let i of spellInfo.spellSuggestions) { + yield i; + } + })(this._spellInfo); + } + + let next = this._suggestionGenerator.next(); + if (next.done) { + this._suggestionGenerator = null; + return ""; + } + return next.value; + }, + + get currentDictionary() { + return this._spellInfo.currentDictionary; + }, + get dictionaryList() { + return this._spellInfo.dictionaryList.slice(); + }, + + selectDictionary(localeCode) { + this._actor.selectDictionary({ localeCode }); + }, + + replaceMisspelling(index) { + this._actor.replaceMisspelling({ index }); + }, + + 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; + }, +}; |