michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: this.EXPORTED_SYMBOLS = [ "InlineSpellChecker" ]; michael@0: var gLanguageBundle; michael@0: var gRegionBundle; michael@0: const MAX_UNDO_STACK_DEPTH = 1; michael@0: michael@0: this.InlineSpellChecker = function InlineSpellChecker(aEditor) { michael@0: this.init(aEditor); michael@0: this.mAddedWordStack = []; // We init this here to preserve it between init/uninit calls michael@0: } michael@0: michael@0: InlineSpellChecker.prototype = { michael@0: // Call this function to initialize for a given editor michael@0: init: function(aEditor) michael@0: { michael@0: this.uninit(); michael@0: this.mEditor = aEditor; michael@0: try { michael@0: this.mInlineSpellChecker = this.mEditor.getInlineSpellChecker(true); michael@0: // note: this might have been NULL if there is no chance we can spellcheck michael@0: } catch(e) { michael@0: this.mInlineSpellChecker = null; michael@0: } michael@0: }, michael@0: michael@0: // call this to clear state michael@0: uninit: function() michael@0: { michael@0: this.mEditor = null; michael@0: this.mInlineSpellChecker = null; michael@0: this.mOverMisspelling = false; michael@0: this.mMisspelling = ""; michael@0: this.mMenu = null; michael@0: this.mSpellSuggestions = []; michael@0: this.mSuggestionItems = []; michael@0: this.mDictionaryMenu = null; michael@0: this.mDictionaryNames = []; michael@0: this.mDictionaryItems = []; michael@0: this.mWordNode = null; michael@0: }, michael@0: michael@0: // for each UI event, you must call this function, it will compute the michael@0: // word the cursor is over michael@0: initFromEvent: function(rangeParent, rangeOffset) michael@0: { michael@0: this.mOverMisspelling = false; michael@0: michael@0: if (!rangeParent || !this.mInlineSpellChecker) michael@0: return; michael@0: michael@0: var selcon = this.mEditor.selectionController; michael@0: var spellsel = selcon.getSelection(selcon.SELECTION_SPELLCHECK); michael@0: if (spellsel.rangeCount == 0) michael@0: return; // easy case - no misspellings michael@0: michael@0: var range = this.mInlineSpellChecker.getMisspelledWord(rangeParent, michael@0: rangeOffset); michael@0: if (! range) michael@0: return; // not over a misspelled word michael@0: michael@0: this.mMisspelling = range.toString(); michael@0: this.mOverMisspelling = true; michael@0: this.mWordNode = rangeParent; michael@0: this.mWordOffset = rangeOffset; michael@0: }, michael@0: michael@0: // returns false if there should be no spellchecking UI enabled at all, true michael@0: // means that you can at least give the user the ability to turn it on. michael@0: get canSpellCheck() michael@0: { michael@0: // inline spell checker objects will be created only if there are actual michael@0: // dictionaries available michael@0: return (this.mInlineSpellChecker != null); michael@0: }, michael@0: michael@0: // Whether spellchecking is enabled in the current box michael@0: get enabled() michael@0: { michael@0: return (this.mInlineSpellChecker && michael@0: this.mInlineSpellChecker.enableRealTimeSpell); michael@0: }, michael@0: set enabled(isEnabled) michael@0: { michael@0: if (this.mInlineSpellChecker) michael@0: this.mEditor.setSpellcheckUserOverride(isEnabled); michael@0: }, michael@0: michael@0: // returns true if the given event is over a misspelled word michael@0: get overMisspelling() michael@0: { michael@0: return this.mOverMisspelling; michael@0: }, michael@0: michael@0: // this prepends up to "maxNumber" suggestions at the given menu position michael@0: // for the word under the cursor. Returns the number of suggestions inserted. michael@0: addSuggestionsToMenu: function(menu, insertBefore, maxNumber) michael@0: { michael@0: if (! this.mInlineSpellChecker || ! this.mOverMisspelling) michael@0: return 0; // nothing to do michael@0: michael@0: var spellchecker = this.mInlineSpellChecker.spellChecker; michael@0: try { michael@0: if (! spellchecker.CheckCurrentWord(this.mMisspelling)) michael@0: return 0; // word seems not misspelled after all (?) michael@0: } catch(e) { michael@0: return 0; michael@0: } michael@0: michael@0: this.mMenu = menu; michael@0: this.mSpellSuggestions = []; michael@0: this.mSuggestionItems = []; michael@0: for (var i = 0; i < maxNumber; i ++) { michael@0: var suggestion = spellchecker.GetSuggestedWord(); michael@0: if (! suggestion.length) michael@0: break; michael@0: this.mSpellSuggestions.push(suggestion); michael@0: michael@0: var item = menu.ownerDocument.createElement("menuitem"); michael@0: this.mSuggestionItems.push(item); michael@0: item.setAttribute("label", suggestion); michael@0: item.setAttribute("value", suggestion); michael@0: // this function thing is necessary to generate a callback with the michael@0: // correct binding of "val" (the index in this loop). michael@0: var callback = function(me, val) { return function(evt) { me.replaceMisspelling(val); } }; michael@0: item.addEventListener("command", callback(this, i), true); michael@0: item.setAttribute("class", "spell-suggestion"); michael@0: menu.insertBefore(item, insertBefore); michael@0: } michael@0: return this.mSpellSuggestions.length; michael@0: }, michael@0: michael@0: // undoes the work of addSuggestionsToMenu for the same menu michael@0: // (call from popup hiding) michael@0: clearSuggestionsFromMenu: function() michael@0: { michael@0: for (var i = 0; i < this.mSuggestionItems.length; i ++) { michael@0: this.mMenu.removeChild(this.mSuggestionItems[i]); michael@0: } michael@0: this.mSuggestionItems = []; michael@0: }, michael@0: michael@0: // returns the number of dictionary languages. If insertBefore is NULL, this michael@0: // does an append to the given menu michael@0: addDictionaryListToMenu: function(menu, insertBefore) michael@0: { michael@0: this.mDictionaryMenu = menu; michael@0: this.mDictionaryNames = []; michael@0: this.mDictionaryItems = []; michael@0: michael@0: if (! this.mInlineSpellChecker || ! this.enabled) michael@0: return 0; michael@0: var spellchecker = this.mInlineSpellChecker.spellChecker; michael@0: var o1 = {}, o2 = {}; michael@0: spellchecker.GetDictionaryList(o1, o2); michael@0: var list = o1.value; michael@0: var listcount = o2.value; michael@0: var curlang = ""; michael@0: try { michael@0: curlang = spellchecker.GetCurrentDictionary(); michael@0: } catch(e) {} michael@0: michael@0: var sortedList = []; michael@0: for (var i = 0; i < list.length; i ++) { michael@0: sortedList.push({"id": list[i], michael@0: "label": this.getDictionaryDisplayName(list[i])}); michael@0: } michael@0: sortedList.sort(function(a, b) { michael@0: if (a.label < b.label) michael@0: return -1; michael@0: if (a.label > b.label) michael@0: return 1; michael@0: return 0; michael@0: }); michael@0: michael@0: for (var i = 0; i < sortedList.length; i ++) { michael@0: this.mDictionaryNames.push(sortedList[i].id); michael@0: var item = menu.ownerDocument.createElement("menuitem"); michael@0: item.setAttribute("id", "spell-check-dictionary-" + sortedList[i].id); michael@0: item.setAttribute("label", sortedList[i].label); michael@0: item.setAttribute("type", "radio"); michael@0: this.mDictionaryItems.push(item); michael@0: if (curlang == sortedList[i].id) { michael@0: item.setAttribute("checked", "true"); michael@0: } else { michael@0: var callback = function(me, val) { return function(evt) { me.selectDictionary(val); } }; michael@0: item.addEventListener("command", callback(this, i), true); michael@0: } michael@0: if (insertBefore) michael@0: menu.insertBefore(item, insertBefore); michael@0: else michael@0: menu.appendChild(item); michael@0: } michael@0: return list.length; michael@0: }, michael@0: michael@0: // Formats a valid BCP 47 language tag based on available localized names. michael@0: getDictionaryDisplayName: function(dictionaryName) { michael@0: try { michael@0: // Get the display name for this dictionary. michael@0: let languageTagMatch = /^([a-z]{2,3}|[a-z]{4}|[a-z]{5,8})(?:[-_]([a-z]{4}))?(?:[-_]([A-Z]{2}|[0-9]{3}))?((?:[-_](?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*)(?:[-_][a-wy-z0-9](?:[-_][a-z0-9]{2,8})+)*(?:[-_]x(?:[-_][a-z0-9]{1,8})+)?$/i; michael@0: var [languageTag, languageSubtag, scriptSubtag, regionSubtag, variantSubtags] = dictionaryName.match(languageTagMatch); michael@0: } catch(e) { michael@0: // If we weren't given a valid language tag, just use the raw dictionary name. michael@0: return dictionaryName; michael@0: } michael@0: michael@0: if (!gLanguageBundle) { michael@0: // Create the bundles for language and region names. michael@0: var bundleService = Components.classes["@mozilla.org/intl/stringbundle;1"] michael@0: .getService(Components.interfaces.nsIStringBundleService); michael@0: gLanguageBundle = bundleService.createBundle( michael@0: "chrome://global/locale/languageNames.properties"); michael@0: gRegionBundle = bundleService.createBundle( michael@0: "chrome://global/locale/regionNames.properties"); michael@0: } michael@0: michael@0: var displayName = ""; michael@0: michael@0: // Language subtag will normally be 2 or 3 letters, but could be up to 8. michael@0: try { michael@0: displayName += gLanguageBundle.GetStringFromName(languageSubtag.toLowerCase()); michael@0: } catch(e) { michael@0: displayName += languageSubtag.toLowerCase(); // Fall back to raw language subtag. michael@0: } michael@0: michael@0: // Region subtag will be 2 letters or 3 digits. michael@0: if (regionSubtag) { michael@0: displayName += " ("; michael@0: michael@0: try { michael@0: displayName += gRegionBundle.GetStringFromName(regionSubtag.toLowerCase()); michael@0: } catch(e) { michael@0: displayName += regionSubtag.toUpperCase(); // Fall back to raw region subtag. michael@0: } michael@0: michael@0: displayName += ")"; michael@0: } michael@0: michael@0: // Script subtag will be 4 letters. michael@0: if (scriptSubtag) { michael@0: displayName += " / "; michael@0: michael@0: // XXX: See bug 666662 and bug 666731 for full implementation. michael@0: displayName += scriptSubtag; // Fall back to raw script subtag. michael@0: } michael@0: michael@0: // Each variant subtag will be 4 to 8 chars. michael@0: if (variantSubtags) michael@0: // XXX: See bug 666662 and bug 666731 for full implementation. michael@0: displayName += " (" + variantSubtags.substr(1).split(/[-_]/).join(" / ") + ")"; // Collapse multiple variants. michael@0: michael@0: return displayName; michael@0: }, michael@0: michael@0: // undoes the work of addDictionaryListToMenu for the menu michael@0: // (call on popup hiding) michael@0: clearDictionaryListFromMenu: function() michael@0: { michael@0: for (var i = 0; i < this.mDictionaryItems.length; i ++) { michael@0: this.mDictionaryMenu.removeChild(this.mDictionaryItems[i]); michael@0: } michael@0: this.mDictionaryItems = []; michael@0: }, michael@0: michael@0: // callback for selecting a dictionary michael@0: selectDictionary: function(index) michael@0: { michael@0: if (! this.mInlineSpellChecker || index < 0 || index >= this.mDictionaryNames.length) michael@0: return; michael@0: var spellchecker = this.mInlineSpellChecker.spellChecker; michael@0: spellchecker.SetCurrentDictionary(this.mDictionaryNames[index]); michael@0: this.mInlineSpellChecker.spellCheckRange(null); // causes recheck michael@0: }, michael@0: michael@0: // callback for selecting a suggesteed replacement michael@0: replaceMisspelling: function(index) michael@0: { michael@0: if (! this.mInlineSpellChecker || ! this.mOverMisspelling) michael@0: return; michael@0: if (index < 0 || index >= this.mSpellSuggestions.length) michael@0: return; michael@0: this.mInlineSpellChecker.replaceWord(this.mWordNode, this.mWordOffset, michael@0: this.mSpellSuggestions[index]); michael@0: }, michael@0: michael@0: // callback for enabling or disabling spellchecking michael@0: toggleEnabled: function() michael@0: { michael@0: this.mEditor.setSpellcheckUserOverride(!this.mInlineSpellChecker.enableRealTimeSpell); michael@0: }, michael@0: michael@0: // callback for adding the current misspelling to the user-defined dictionary michael@0: addToDictionary: function() michael@0: { michael@0: // Prevent the undo stack from growing over the max depth michael@0: if (this.mAddedWordStack.length == MAX_UNDO_STACK_DEPTH) michael@0: this.mAddedWordStack.shift(); michael@0: michael@0: this.mAddedWordStack.push(this.mMisspelling); michael@0: this.mInlineSpellChecker.addWordToDictionary(this.mMisspelling); michael@0: }, michael@0: // callback for removing the last added word to the dictionary LIFO fashion michael@0: undoAddToDictionary: function() michael@0: { michael@0: if (this.mAddedWordStack.length > 0) michael@0: { michael@0: var word = this.mAddedWordStack.pop(); michael@0: this.mInlineSpellChecker.removeWordFromDictionary(word); michael@0: } michael@0: }, michael@0: canUndo : function() michael@0: { michael@0: // Return true if we have words on the stack michael@0: return (this.mAddedWordStack.length > 0); michael@0: }, michael@0: ignoreWord: function() michael@0: { michael@0: this.mInlineSpellChecker.ignoreWord(this.mMisspelling); michael@0: } michael@0: };