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