|
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 |
|
5 this.EXPORTED_SYMBOLS = [ "InlineSpellChecker" ]; |
|
6 var gLanguageBundle; |
|
7 var gRegionBundle; |
|
8 const MAX_UNDO_STACK_DEPTH = 1; |
|
9 |
|
10 this.InlineSpellChecker = function InlineSpellChecker(aEditor) { |
|
11 this.init(aEditor); |
|
12 this.mAddedWordStack = []; // We init this here to preserve it between init/uninit calls |
|
13 } |
|
14 |
|
15 InlineSpellChecker.prototype = { |
|
16 // Call this function to initialize for a given editor |
|
17 init: function(aEditor) |
|
18 { |
|
19 this.uninit(); |
|
20 this.mEditor = aEditor; |
|
21 try { |
|
22 this.mInlineSpellChecker = this.mEditor.getInlineSpellChecker(true); |
|
23 // note: this might have been NULL if there is no chance we can spellcheck |
|
24 } catch(e) { |
|
25 this.mInlineSpellChecker = null; |
|
26 } |
|
27 }, |
|
28 |
|
29 // call this to clear state |
|
30 uninit: function() |
|
31 { |
|
32 this.mEditor = null; |
|
33 this.mInlineSpellChecker = null; |
|
34 this.mOverMisspelling = false; |
|
35 this.mMisspelling = ""; |
|
36 this.mMenu = null; |
|
37 this.mSpellSuggestions = []; |
|
38 this.mSuggestionItems = []; |
|
39 this.mDictionaryMenu = null; |
|
40 this.mDictionaryNames = []; |
|
41 this.mDictionaryItems = []; |
|
42 this.mWordNode = null; |
|
43 }, |
|
44 |
|
45 // for each UI event, you must call this function, it will compute the |
|
46 // word the cursor is over |
|
47 initFromEvent: function(rangeParent, rangeOffset) |
|
48 { |
|
49 this.mOverMisspelling = false; |
|
50 |
|
51 if (!rangeParent || !this.mInlineSpellChecker) |
|
52 return; |
|
53 |
|
54 var selcon = this.mEditor.selectionController; |
|
55 var spellsel = selcon.getSelection(selcon.SELECTION_SPELLCHECK); |
|
56 if (spellsel.rangeCount == 0) |
|
57 return; // easy case - no misspellings |
|
58 |
|
59 var range = this.mInlineSpellChecker.getMisspelledWord(rangeParent, |
|
60 rangeOffset); |
|
61 if (! range) |
|
62 return; // not over a misspelled word |
|
63 |
|
64 this.mMisspelling = range.toString(); |
|
65 this.mOverMisspelling = true; |
|
66 this.mWordNode = rangeParent; |
|
67 this.mWordOffset = rangeOffset; |
|
68 }, |
|
69 |
|
70 // returns false if there should be no spellchecking UI enabled at all, true |
|
71 // means that you can at least give the user the ability to turn it on. |
|
72 get canSpellCheck() |
|
73 { |
|
74 // inline spell checker objects will be created only if there are actual |
|
75 // dictionaries available |
|
76 return (this.mInlineSpellChecker != null); |
|
77 }, |
|
78 |
|
79 // Whether spellchecking is enabled in the current box |
|
80 get enabled() |
|
81 { |
|
82 return (this.mInlineSpellChecker && |
|
83 this.mInlineSpellChecker.enableRealTimeSpell); |
|
84 }, |
|
85 set enabled(isEnabled) |
|
86 { |
|
87 if (this.mInlineSpellChecker) |
|
88 this.mEditor.setSpellcheckUserOverride(isEnabled); |
|
89 }, |
|
90 |
|
91 // returns true if the given event is over a misspelled word |
|
92 get overMisspelling() |
|
93 { |
|
94 return this.mOverMisspelling; |
|
95 }, |
|
96 |
|
97 // this prepends up to "maxNumber" suggestions at the given menu position |
|
98 // for the word under the cursor. Returns the number of suggestions inserted. |
|
99 addSuggestionsToMenu: function(menu, insertBefore, maxNumber) |
|
100 { |
|
101 if (! this.mInlineSpellChecker || ! this.mOverMisspelling) |
|
102 return 0; // nothing to do |
|
103 |
|
104 var spellchecker = this.mInlineSpellChecker.spellChecker; |
|
105 try { |
|
106 if (! spellchecker.CheckCurrentWord(this.mMisspelling)) |
|
107 return 0; // word seems not misspelled after all (?) |
|
108 } catch(e) { |
|
109 return 0; |
|
110 } |
|
111 |
|
112 this.mMenu = menu; |
|
113 this.mSpellSuggestions = []; |
|
114 this.mSuggestionItems = []; |
|
115 for (var i = 0; i < maxNumber; i ++) { |
|
116 var suggestion = spellchecker.GetSuggestedWord(); |
|
117 if (! suggestion.length) |
|
118 break; |
|
119 this.mSpellSuggestions.push(suggestion); |
|
120 |
|
121 var item = menu.ownerDocument.createElement("menuitem"); |
|
122 this.mSuggestionItems.push(item); |
|
123 item.setAttribute("label", suggestion); |
|
124 item.setAttribute("value", suggestion); |
|
125 // this function thing is necessary to generate a callback with the |
|
126 // correct binding of "val" (the index in this loop). |
|
127 var callback = function(me, val) { return function(evt) { me.replaceMisspelling(val); } }; |
|
128 item.addEventListener("command", callback(this, i), true); |
|
129 item.setAttribute("class", "spell-suggestion"); |
|
130 menu.insertBefore(item, insertBefore); |
|
131 } |
|
132 return this.mSpellSuggestions.length; |
|
133 }, |
|
134 |
|
135 // undoes the work of addSuggestionsToMenu for the same menu |
|
136 // (call from popup hiding) |
|
137 clearSuggestionsFromMenu: function() |
|
138 { |
|
139 for (var i = 0; i < this.mSuggestionItems.length; i ++) { |
|
140 this.mMenu.removeChild(this.mSuggestionItems[i]); |
|
141 } |
|
142 this.mSuggestionItems = []; |
|
143 }, |
|
144 |
|
145 // returns the number of dictionary languages. If insertBefore is NULL, this |
|
146 // does an append to the given menu |
|
147 addDictionaryListToMenu: function(menu, insertBefore) |
|
148 { |
|
149 this.mDictionaryMenu = menu; |
|
150 this.mDictionaryNames = []; |
|
151 this.mDictionaryItems = []; |
|
152 |
|
153 if (! this.mInlineSpellChecker || ! this.enabled) |
|
154 return 0; |
|
155 var spellchecker = this.mInlineSpellChecker.spellChecker; |
|
156 var o1 = {}, o2 = {}; |
|
157 spellchecker.GetDictionaryList(o1, o2); |
|
158 var list = o1.value; |
|
159 var listcount = o2.value; |
|
160 var curlang = ""; |
|
161 try { |
|
162 curlang = spellchecker.GetCurrentDictionary(); |
|
163 } catch(e) {} |
|
164 |
|
165 var sortedList = []; |
|
166 for (var i = 0; i < list.length; i ++) { |
|
167 sortedList.push({"id": list[i], |
|
168 "label": this.getDictionaryDisplayName(list[i])}); |
|
169 } |
|
170 sortedList.sort(function(a, b) { |
|
171 if (a.label < b.label) |
|
172 return -1; |
|
173 if (a.label > b.label) |
|
174 return 1; |
|
175 return 0; |
|
176 }); |
|
177 |
|
178 for (var i = 0; i < sortedList.length; i ++) { |
|
179 this.mDictionaryNames.push(sortedList[i].id); |
|
180 var item = menu.ownerDocument.createElement("menuitem"); |
|
181 item.setAttribute("id", "spell-check-dictionary-" + sortedList[i].id); |
|
182 item.setAttribute("label", sortedList[i].label); |
|
183 item.setAttribute("type", "radio"); |
|
184 this.mDictionaryItems.push(item); |
|
185 if (curlang == sortedList[i].id) { |
|
186 item.setAttribute("checked", "true"); |
|
187 } else { |
|
188 var callback = function(me, val) { return function(evt) { me.selectDictionary(val); } }; |
|
189 item.addEventListener("command", callback(this, i), true); |
|
190 } |
|
191 if (insertBefore) |
|
192 menu.insertBefore(item, insertBefore); |
|
193 else |
|
194 menu.appendChild(item); |
|
195 } |
|
196 return list.length; |
|
197 }, |
|
198 |
|
199 // Formats a valid BCP 47 language tag based on available localized names. |
|
200 getDictionaryDisplayName: function(dictionaryName) { |
|
201 try { |
|
202 // Get the display name for this dictionary. |
|
203 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; |
|
204 var [languageTag, languageSubtag, scriptSubtag, regionSubtag, variantSubtags] = dictionaryName.match(languageTagMatch); |
|
205 } catch(e) { |
|
206 // If we weren't given a valid language tag, just use the raw dictionary name. |
|
207 return dictionaryName; |
|
208 } |
|
209 |
|
210 if (!gLanguageBundle) { |
|
211 // Create the bundles for language and region names. |
|
212 var bundleService = Components.classes["@mozilla.org/intl/stringbundle;1"] |
|
213 .getService(Components.interfaces.nsIStringBundleService); |
|
214 gLanguageBundle = bundleService.createBundle( |
|
215 "chrome://global/locale/languageNames.properties"); |
|
216 gRegionBundle = bundleService.createBundle( |
|
217 "chrome://global/locale/regionNames.properties"); |
|
218 } |
|
219 |
|
220 var displayName = ""; |
|
221 |
|
222 // Language subtag will normally be 2 or 3 letters, but could be up to 8. |
|
223 try { |
|
224 displayName += gLanguageBundle.GetStringFromName(languageSubtag.toLowerCase()); |
|
225 } catch(e) { |
|
226 displayName += languageSubtag.toLowerCase(); // Fall back to raw language subtag. |
|
227 } |
|
228 |
|
229 // Region subtag will be 2 letters or 3 digits. |
|
230 if (regionSubtag) { |
|
231 displayName += " ("; |
|
232 |
|
233 try { |
|
234 displayName += gRegionBundle.GetStringFromName(regionSubtag.toLowerCase()); |
|
235 } catch(e) { |
|
236 displayName += regionSubtag.toUpperCase(); // Fall back to raw region subtag. |
|
237 } |
|
238 |
|
239 displayName += ")"; |
|
240 } |
|
241 |
|
242 // Script subtag will be 4 letters. |
|
243 if (scriptSubtag) { |
|
244 displayName += " / "; |
|
245 |
|
246 // XXX: See bug 666662 and bug 666731 for full implementation. |
|
247 displayName += scriptSubtag; // Fall back to raw script subtag. |
|
248 } |
|
249 |
|
250 // Each variant subtag will be 4 to 8 chars. |
|
251 if (variantSubtags) |
|
252 // XXX: See bug 666662 and bug 666731 for full implementation. |
|
253 displayName += " (" + variantSubtags.substr(1).split(/[-_]/).join(" / ") + ")"; // Collapse multiple variants. |
|
254 |
|
255 return displayName; |
|
256 }, |
|
257 |
|
258 // undoes the work of addDictionaryListToMenu for the menu |
|
259 // (call on popup hiding) |
|
260 clearDictionaryListFromMenu: function() |
|
261 { |
|
262 for (var i = 0; i < this.mDictionaryItems.length; i ++) { |
|
263 this.mDictionaryMenu.removeChild(this.mDictionaryItems[i]); |
|
264 } |
|
265 this.mDictionaryItems = []; |
|
266 }, |
|
267 |
|
268 // callback for selecting a dictionary |
|
269 selectDictionary: function(index) |
|
270 { |
|
271 if (! this.mInlineSpellChecker || index < 0 || index >= this.mDictionaryNames.length) |
|
272 return; |
|
273 var spellchecker = this.mInlineSpellChecker.spellChecker; |
|
274 spellchecker.SetCurrentDictionary(this.mDictionaryNames[index]); |
|
275 this.mInlineSpellChecker.spellCheckRange(null); // causes recheck |
|
276 }, |
|
277 |
|
278 // callback for selecting a suggesteed replacement |
|
279 replaceMisspelling: function(index) |
|
280 { |
|
281 if (! this.mInlineSpellChecker || ! this.mOverMisspelling) |
|
282 return; |
|
283 if (index < 0 || index >= this.mSpellSuggestions.length) |
|
284 return; |
|
285 this.mInlineSpellChecker.replaceWord(this.mWordNode, this.mWordOffset, |
|
286 this.mSpellSuggestions[index]); |
|
287 }, |
|
288 |
|
289 // callback for enabling or disabling spellchecking |
|
290 toggleEnabled: function() |
|
291 { |
|
292 this.mEditor.setSpellcheckUserOverride(!this.mInlineSpellChecker.enableRealTimeSpell); |
|
293 }, |
|
294 |
|
295 // callback for adding the current misspelling to the user-defined dictionary |
|
296 addToDictionary: function() |
|
297 { |
|
298 // Prevent the undo stack from growing over the max depth |
|
299 if (this.mAddedWordStack.length == MAX_UNDO_STACK_DEPTH) |
|
300 this.mAddedWordStack.shift(); |
|
301 |
|
302 this.mAddedWordStack.push(this.mMisspelling); |
|
303 this.mInlineSpellChecker.addWordToDictionary(this.mMisspelling); |
|
304 }, |
|
305 // callback for removing the last added word to the dictionary LIFO fashion |
|
306 undoAddToDictionary: function() |
|
307 { |
|
308 if (this.mAddedWordStack.length > 0) |
|
309 { |
|
310 var word = this.mAddedWordStack.pop(); |
|
311 this.mInlineSpellChecker.removeWordFromDictionary(word); |
|
312 } |
|
313 }, |
|
314 canUndo : function() |
|
315 { |
|
316 // Return true if we have words on the stack |
|
317 return (this.mAddedWordStack.length > 0); |
|
318 }, |
|
319 ignoreWord: function() |
|
320 { |
|
321 this.mInlineSpellChecker.ignoreWord(this.mMisspelling); |
|
322 } |
|
323 }; |