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/. */ 4 5this.EXPORTED_SYMBOLS = [ "InlineSpellChecker", 6 "SpellCheckHelper" ]; 7var gLanguageBundle; 8var gRegionBundle; 9const MAX_UNDO_STACK_DEPTH = 1; 10 11const Cc = Components.classes; 12const Ci = Components.interfaces; 13const Cu = Components.utils; 14 15this.InlineSpellChecker = function InlineSpellChecker(aEditor) { 16 this.init(aEditor); 17 this.mAddedWordStack = []; // We init this here to preserve it between init/uninit calls 18} 19 20InlineSpellChecker.prototype = { 21 // Call this function to initialize for a given editor 22 init: function(aEditor) 23 { 24 this.uninit(); 25 this.mEditor = aEditor; 26 try { 27 this.mInlineSpellChecker = this.mEditor.getInlineSpellChecker(true); 28 // note: this might have been NULL if there is no chance we can spellcheck 29 } catch (e) { 30 this.mInlineSpellChecker = null; 31 } 32 }, 33 34 initFromRemote: function(aSpellInfo) 35 { 36 if (this.mRemote) 37 throw new Error("Unexpected state"); 38 this.uninit(); 39 40 if (!aSpellInfo) 41 return; 42 this.mInlineSpellChecker = this.mRemote = new RemoteSpellChecker(aSpellInfo); 43 this.mOverMisspelling = aSpellInfo.overMisspelling; 44 this.mMisspelling = aSpellInfo.misspelling; 45 }, 46 47 // call this to clear state 48 uninit: function() 49 { 50 if (this.mRemote) { 51 this.mRemote.uninit(); 52 this.mRemote = null; 53 } 54 55 this.mEditor = null; 56 this.mInlineSpellChecker = null; 57 this.mOverMisspelling = false; 58 this.mMisspelling = ""; 59 this.mMenu = null; 60 this.mSpellSuggestions = []; 61 this.mSuggestionItems = []; 62 this.mDictionaryMenu = null; 63 this.mDictionaryNames = []; 64 this.mDictionaryItems = []; 65 this.mWordNode = null; 66 }, 67 68 // for each UI event, you must call this function, it will compute the 69 // word the cursor is over 70 initFromEvent: function(rangeParent, rangeOffset) 71 { 72 this.mOverMisspelling = false; 73 74 if (!rangeParent || !this.mInlineSpellChecker) 75 return; 76 77 var selcon = this.mEditor.selectionController; 78 var spellsel = selcon.getSelection(selcon.SELECTION_SPELLCHECK); 79 if (spellsel.rangeCount == 0) 80 return; // easy case - no misspellings 81 82 var range = this.mInlineSpellChecker.getMisspelledWord(rangeParent, 83 rangeOffset); 84 if (! range) 85 return; // not over a misspelled word 86 87 this.mMisspelling = range.toString(); 88 this.mOverMisspelling = true; 89 this.mWordNode = rangeParent; 90 this.mWordOffset = rangeOffset; 91 }, 92 93 // returns false if there should be no spellchecking UI enabled at all, true 94 // means that you can at least give the user the ability to turn it on. 95 get canSpellCheck() 96 { 97 // inline spell checker objects will be created only if there are actual 98 // dictionaries available 99 if (this.mRemote) 100 return this.mRemote.canSpellCheck; 101 return this.mInlineSpellChecker != null; 102 }, 103 104 get initialSpellCheckPending() { 105 if (this.mRemote) { 106 return this.mRemote.spellCheckPending; 107 } 108 return !!(this.mInlineSpellChecker && 109 !this.mInlineSpellChecker.spellChecker && 110 this.mInlineSpellChecker.spellCheckPending); 111 }, 112 113 // Whether spellchecking is enabled in the current box 114 get enabled() 115 { 116 if (this.mRemote) 117 return this.mRemote.enableRealTimeSpell; 118 return (this.mInlineSpellChecker && 119 this.mInlineSpellChecker.enableRealTimeSpell); 120 }, 121 set enabled(isEnabled) 122 { 123 if (this.mRemote) 124 this.mRemote.setSpellcheckUserOverride(isEnabled); 125 else if (this.mInlineSpellChecker) 126 this.mEditor.setSpellcheckUserOverride(isEnabled); 127 }, 128 129 // returns true if the given event is over a misspelled word 130 get overMisspelling() 131 { 132 return this.mOverMisspelling; 133 }, 134 135 // this prepends up to "maxNumber" suggestions at the given menu position 136 // for the word under the cursor. Returns the number of suggestions inserted. 137 addSuggestionsToMenu: function(menu, insertBefore, maxNumber) 138 { 139 if (!this.mRemote && (!this.mInlineSpellChecker || !this.mOverMisspelling)) 140 return 0; // nothing to do 141 142 var spellchecker = this.mRemote || this.mInlineSpellChecker.spellChecker; 143 try { 144 if (!this.mRemote && !spellchecker.CheckCurrentWord(this.mMisspelling)) 145 return 0; // word seems not misspelled after all (?) 146 } catch (e) { 147 return 0; 148 } 149 150 this.mMenu = menu; 151 this.mSpellSuggestions = []; 152 this.mSuggestionItems = []; 153 for (var i = 0; i < maxNumber; i ++) { 154 var suggestion = spellchecker.GetSuggestedWord(); 155 if (! suggestion.length) 156 break; 157 this.mSpellSuggestions.push(suggestion); 158 159 var item = menu.ownerDocument.createElement("menuitem"); 160 this.mSuggestionItems.push(item); 161 item.setAttribute("label", suggestion); 162 item.setAttribute("value", suggestion); 163 // this function thing is necessary to generate a callback with the 164 // correct binding of "val" (the index in this loop). 165 var callback = function(me, val) { return function(evt) { me.replaceMisspelling(val); } }; 166 item.addEventListener("command", callback(this, i), true); 167 item.setAttribute("class", "spell-suggestion"); 168 menu.insertBefore(item, insertBefore); 169 } 170 return this.mSpellSuggestions.length; 171 }, 172 173 // undoes the work of addSuggestionsToMenu for the same menu 174 // (call from popup hiding) 175 clearSuggestionsFromMenu: function() 176 { 177 for (var i = 0; i < this.mSuggestionItems.length; i ++) { 178 this.mMenu.removeChild(this.mSuggestionItems[i]); 179 } 180 this.mSuggestionItems = []; 181 }, 182 183 sortDictionaryList: function(list) { 184 var sortedList = []; 185 for (var i = 0; i < list.length; i ++) { 186 sortedList.push({"id": list[i], 187 "label": this.getDictionaryDisplayName(list[i])}); 188 } 189 sortedList.sort(function(a, b) { 190 if (a.label < b.label) 191 return -1; 192 if (a.label > b.label) 193 return 1; 194 return 0; 195 }); 196 197 return sortedList; 198 }, 199 200 // returns the number of dictionary languages. If insertBefore is NULL, this 201 // does an append to the given menu 202 addDictionaryListToMenu: function(menu, insertBefore) 203 { 204 this.mDictionaryMenu = menu; 205 this.mDictionaryNames = []; 206 this.mDictionaryItems = []; 207 208 if (!this.enabled) 209 return 0; 210 211 var list; 212 var curlang = ""; 213 if (this.mRemote) { 214 list = this.mRemote.dictionaryList; 215 curlang = this.mRemote.currentDictionary; 216 } 217 else if (this.mInlineSpellChecker) { 218 var spellchecker = this.mInlineSpellChecker.spellChecker; 219 var o1 = {}, o2 = {}; 220 spellchecker.GetDictionaryList(o1, o2); 221 list = o1.value; 222 var listcount = o2.value; 223 try { 224 curlang = spellchecker.GetCurrentDictionary(); 225 } catch (e) {} 226 } 227 228 var sortedList = this.sortDictionaryList(list); 229 230 for (var i = 0; i < sortedList.length; i ++) { 231 this.mDictionaryNames.push(sortedList[i].id); 232 var item = menu.ownerDocument.createElement("menuitem"); 233 item.setAttribute("id", "spell-check-dictionary-" + sortedList[i].id); 234 item.setAttribute("label", sortedList[i].label); 235 item.setAttribute("type", "radio"); 236 this.mDictionaryItems.push(item); 237 if (curlang == sortedList[i].id) { 238 item.setAttribute("checked", "true"); 239 } else { 240 var callback = function(me, val, dictName) { 241 return function(evt) { 242 me.selectDictionary(val); 243 // Notify change of dictionary, especially for Thunderbird, 244 // which is otherwise not notified any more. 245 var view = menu.ownerDocument.defaultView; 246 var spellcheckChangeEvent = new view.CustomEvent( 247 "spellcheck-changed", {detail: { dictionary: dictName}}); 248 menu.ownerDocument.dispatchEvent(spellcheckChangeEvent); 249 } 250 }; 251 item.addEventListener("command", callback(this, i, sortedList[i].id), true); 252 } 253 if (insertBefore) 254 menu.insertBefore(item, insertBefore); 255 else 256 menu.appendChild(item); 257 } 258 return list.length; 259 }, 260 261 // Formats a valid BCP 47 language tag based on available localized names. 262 getDictionaryDisplayName: function(dictionaryName) { 263 try { 264 // Get the display name for this dictionary. 265 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; 266 var [languageTag, languageSubtag, scriptSubtag, regionSubtag, variantSubtags] = dictionaryName.match(languageTagMatch); 267 } catch (e) { 268 // If we weren't given a valid language tag, just use the raw dictionary name. 269 return dictionaryName; 270 } 271 272 if (!gLanguageBundle) { 273 // Create the bundles for language and region names. 274 var bundleService = Components.classes["@mozilla.org/intl/stringbundle;1"] 275 .getService(Components.interfaces.nsIStringBundleService); 276 gLanguageBundle = bundleService.createBundle( 277 "chrome://global/locale/languageNames.properties"); 278 gRegionBundle = bundleService.createBundle( 279 "chrome://global/locale/regionNames.properties"); 280 } 281 282 var displayName = ""; 283 284 // Language subtag will normally be 2 or 3 letters, but could be up to 8. 285 try { 286 displayName += gLanguageBundle.GetStringFromName(languageSubtag.toLowerCase()); 287 } catch (e) { 288 displayName += languageSubtag.toLowerCase(); // Fall back to raw language subtag. 289 } 290 291 // Region subtag will be 2 letters or 3 digits. 292 if (regionSubtag) { 293 displayName += " ("; 294 295 try { 296 displayName += gRegionBundle.GetStringFromName(regionSubtag.toLowerCase()); 297 } catch (e) { 298 displayName += regionSubtag.toUpperCase(); // Fall back to raw region subtag. 299 } 300 301 displayName += ")"; 302 } 303 304 // Script subtag will be 4 letters. 305 if (scriptSubtag) { 306 displayName += " / "; 307 308 // XXX: See bug 666662 and bug 666731 for full implementation. 309 displayName += scriptSubtag; // Fall back to raw script subtag. 310 } 311 312 // Each variant subtag will be 4 to 8 chars. 313 if (variantSubtags) 314 // XXX: See bug 666662 and bug 666731 for full implementation. 315 displayName += " (" + variantSubtags.substr(1).split(/[-_]/).join(" / ") + ")"; // Collapse multiple variants. 316 317 return displayName; 318 }, 319 320 // undoes the work of addDictionaryListToMenu for the menu 321 // (call on popup hiding) 322 clearDictionaryListFromMenu: function() 323 { 324 for (var i = 0; i < this.mDictionaryItems.length; i ++) { 325 this.mDictionaryMenu.removeChild(this.mDictionaryItems[i]); 326 } 327 this.mDictionaryItems = []; 328 }, 329 330 // callback for selecting a dictionary 331 selectDictionary: function(index) 332 { 333 if (this.mRemote) { 334 this.mRemote.selectDictionary(index); 335 return; 336 } 337 if (! this.mInlineSpellChecker || index < 0 || index >= this.mDictionaryNames.length) 338 return; 339 var spellchecker = this.mInlineSpellChecker.spellChecker; 340 spellchecker.SetCurrentDictionary(this.mDictionaryNames[index]); 341 this.mInlineSpellChecker.spellCheckRange(null); // causes recheck 342 }, 343 344 // callback for selecting a suggested replacement 345 replaceMisspelling: function(index) 346 { 347 if (this.mRemote) { 348 this.mRemote.replaceMisspelling(index); 349 return; 350 } 351 if (! this.mInlineSpellChecker || ! this.mOverMisspelling) 352 return; 353 if (index < 0 || index >= this.mSpellSuggestions.length) 354 return; 355 this.mInlineSpellChecker.replaceWord(this.mWordNode, this.mWordOffset, 356 this.mSpellSuggestions[index]); 357 }, 358 359 // callback for enabling or disabling spellchecking 360 toggleEnabled: function() 361 { 362 if (this.mRemote) 363 this.mRemote.toggleEnabled(); 364 else 365 this.mEditor.setSpellcheckUserOverride(!this.mInlineSpellChecker.enableRealTimeSpell); 366 }, 367 368 // callback for adding the current misspelling to the user-defined dictionary 369 addToDictionary: function() 370 { 371 // Prevent the undo stack from growing over the max depth 372 if (this.mAddedWordStack.length == MAX_UNDO_STACK_DEPTH) 373 this.mAddedWordStack.shift(); 374 375 this.mAddedWordStack.push(this.mMisspelling); 376 if (this.mRemote) 377 this.mRemote.addToDictionary(); 378 else { 379 this.mInlineSpellChecker.addWordToDictionary(this.mMisspelling); 380 } 381 }, 382 // callback for removing the last added word to the dictionary LIFO fashion 383 undoAddToDictionary: function() 384 { 385 if (this.mAddedWordStack.length > 0) 386 { 387 var word = this.mAddedWordStack.pop(); 388 if (this.mRemote) 389 this.mRemote.undoAddToDictionary(word); 390 else 391 this.mInlineSpellChecker.removeWordFromDictionary(word); 392 } 393 }, 394 canUndo : function() 395 { 396 // Return true if we have words on the stack 397 return (this.mAddedWordStack.length > 0); 398 }, 399 ignoreWord: function() 400 { 401 if (this.mRemote) 402 this.mRemote.ignoreWord(); 403 else 404 this.mInlineSpellChecker.ignoreWord(this.mMisspelling); 405 } 406}; 407 408var SpellCheckHelper = { 409 // Set when over a non-read-only <textarea> or editable <input>. 410 EDITABLE: 0x1, 411 412 // Set when over an <input> element of any type. 413 INPUT: 0x2, 414 415 // Set when over any <textarea>. 416 TEXTAREA: 0x4, 417 418 // Set when over any text-entry <input>. 419 TEXTINPUT: 0x8, 420 421 // Set when over an <input> that can be used as a keyword field. 422 KEYWORD: 0x10, 423 424 // Set when over an element that otherwise would not be considered 425 // "editable" but is because content editable is enabled for the document. 426 CONTENTEDITABLE: 0x20, 427 428 // Set when over an <input type="number"> or other non-text field. 429 NUMERIC: 0x40, 430 431 // Set when over an <input type="password"> field. 432 PASSWORD: 0x80, 433 434 isTargetAKeywordField(aNode, window) { 435 if (!(aNode instanceof window.HTMLInputElement)) 436 return false; 437 438 var form = aNode.form; 439 if (!form || aNode.type == "password") 440 return false; 441 442 var method = form.method.toUpperCase(); 443 444 // These are the following types of forms we can create keywords for: 445 // 446 // method encoding type can create keyword 447 // GET * YES 448 // * YES 449 // POST YES 450 // POST application/x-www-form-urlencoded YES 451 // POST text/plain NO (a little tricky to do) 452 // POST multipart/form-data NO 453 // POST everything else YES 454 return (method == "GET" || method == "") || 455 (form.enctype != "text/plain") && (form.enctype != "multipart/form-data"); 456 }, 457 458 // Returns the computed style attribute for the given element. 459 getComputedStyle(aElem, aProp) { 460 return aElem.ownerDocument 461 .defaultView 462 .getComputedStyle(aElem, "").getPropertyValue(aProp); 463 }, 464 465 isEditable(element, window) { 466 var flags = 0; 467 if (element instanceof window.HTMLInputElement) { 468 flags |= this.INPUT; 469 470 if (element.mozIsTextField(false) || element.type == "number") { 471 flags |= this.TEXTINPUT; 472 473 if (element.type == "number") { 474 flags |= this.NUMERIC; 475 } 476 477 // Allow spellchecking UI on all text and search inputs. 478 if (!element.readOnly && 479 (element.type == "text" || element.type == "search")) { 480 flags |= this.EDITABLE; 481 } 482 if (this.isTargetAKeywordField(element, window)) 483 flags |= this.KEYWORD; 484 if (element.type == "password") { 485 flags |= this.PASSWORD; 486 } 487 } 488 } else if (element instanceof window.HTMLTextAreaElement) { 489 flags |= this.TEXTINPUT | this.TEXTAREA; 490 if (!element.readOnly) { 491 flags |= this.EDITABLE; 492 } 493 } 494 495 if (!(flags & this.EDITABLE)) { 496 var win = element.ownerDocument.defaultView; 497 if (win) { 498 var isEditable = false; 499 try { 500 var editingSession = win.QueryInterface(Ci.nsIInterfaceRequestor) 501 .getInterface(Ci.nsIWebNavigation) 502 .QueryInterface(Ci.nsIInterfaceRequestor) 503 .getInterface(Ci.nsIEditingSession); 504 if (editingSession.windowIsEditable(win) && 505 this.getComputedStyle(element, "-moz-user-modify") == "read-write") { 506 isEditable = true; 507 } 508 } 509 catch (ex) { 510 // If someone built with composer disabled, we can't get an editing session. 511 } 512 513 if (isEditable) 514 flags |= this.CONTENTEDITABLE; 515 } 516 } 517 518 return flags; 519 }, 520}; 521 522function RemoteSpellChecker(aSpellInfo) { 523 this._spellInfo = aSpellInfo; 524 this._suggestionGenerator = null; 525} 526 527RemoteSpellChecker.prototype = { 528 get canSpellCheck() { return this._spellInfo.canSpellCheck; }, 529 get spellCheckPending() { return this._spellInfo.initialSpellCheckPending; }, 530 get overMisspelling() { return this._spellInfo.overMisspelling; }, 531 get enableRealTimeSpell() { return this._spellInfo.enableRealTimeSpell; }, 532 533 GetSuggestedWord() { 534 if (!this._suggestionGenerator) { 535 this._suggestionGenerator = (function*(spellInfo) { 536 for (let i of spellInfo.spellSuggestions) 537 yield i; 538 })(this._spellInfo); 539 } 540 541 let next = this._suggestionGenerator.next(); 542 if (next.done) { 543 this._suggestionGenerator = null; 544 return ""; 545 } 546 return next.value; 547 }, 548 549 get currentDictionary() { return this._spellInfo.currentDictionary }, 550 get dictionaryList() { return this._spellInfo.dictionaryList.slice(); }, 551 552 selectDictionary(index) { 553 this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:selectDictionary", 554 { index }); 555 }, 556 557 replaceMisspelling(index) { 558 this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:replaceMisspelling", 559 { index }); 560 }, 561 562 toggleEnabled() { this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:toggleEnabled", {}); }, 563 addToDictionary() { 564 // This is really ugly. There is an nsISpellChecker somewhere in the 565 // parent that corresponds to our current element's spell checker in the 566 // child, but it's hard to access it. However, we know that 567 // addToDictionary adds the word to the singleton personal dictionary, so 568 // we just do that here. 569 // NB: We also rely on the fact that we only ever pass an empty string in 570 // as the "lang". 571 572 let dictionary = Cc["@mozilla.org/spellchecker/personaldictionary;1"] 573 .getService(Ci.mozIPersonalDictionary); 574 dictionary.addWord(this._spellInfo.misspelling, ""); 575 576 this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:recheck", {}); 577 }, 578 undoAddToDictionary(word) { 579 let dictionary = Cc["@mozilla.org/spellchecker/personaldictionary;1"] 580 .getService(Ci.mozIPersonalDictionary); 581 dictionary.removeWord(word, ""); 582 583 this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:recheck", {}); 584 }, 585 ignoreWord() { 586 let dictionary = Cc["@mozilla.org/spellchecker/personaldictionary;1"] 587 .getService(Ci.mozIPersonalDictionary); 588 dictionary.ignoreWord(this._spellInfo.misspelling); 589 590 this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:recheck", {}); 591 }, 592 uninit() { this._spellInfo.target.sendAsyncMessage("InlineSpellChecker:uninit", {}); } 593}; 594