forked from mirrors/gecko-dev
This changes both the spellchecker parent code that interfaces with the InlineSpellCheckerParent actor, and the child code interfacing with the ContextMenuChild actor, to ensure they get notified when either actor goes away. It maintains the "uninit" messages to clear out spellcheck data when the context menu goes away (while the window / actors remain intact). It also adds some belts-and-suspenders type checks that allow us to recover if we ever get in a bad state again, instead of stubbornly throwing exceptions and breaking the UI for users. Differential Revision: https://phabricator.services.mozilla.com/D75228
594 lines
17 KiB
JavaScript
594 lines
17 KiB
JavaScript
/* 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;
|
|
},
|
|
};
|