summaryrefslogtreecommitdiffstats
path: root/editor/spellchecker
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /editor/spellchecker
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'editor/spellchecker')
-rw-r--r--editor/spellchecker/EditorSpellCheck.cpp1183
-rw-r--r--editor/spellchecker/EditorSpellCheck.h99
-rw-r--r--editor/spellchecker/FilteredContentIterator.cpp398
-rw-r--r--editor/spellchecker/FilteredContentIterator.h82
-rw-r--r--editor/spellchecker/TextServicesDocument.cpp2787
-rw-r--r--editor/spellchecker/TextServicesDocument.h436
-rw-r--r--editor/spellchecker/moz.build34
-rw-r--r--editor/spellchecker/nsComposeTxtSrvFilter.cpp64
-rw-r--r--editor/spellchecker/nsComposeTxtSrvFilter.h45
-rw-r--r--editor/spellchecker/nsIInlineSpellChecker.idl40
-rw-r--r--editor/spellchecker/tests/bug1200533_subframe.html15
-rw-r--r--editor/spellchecker/tests/bug1204147_subframe.html11
-rw-r--r--editor/spellchecker/tests/bug1204147_subframe2.html9
-rw-r--r--editor/spellchecker/tests/bug678842_subframe.html8
-rw-r--r--editor/spellchecker/tests/bug717433_subframe.html8
-rw-r--r--editor/spellchecker/tests/chrome.toml7
-rw-r--r--editor/spellchecker/tests/de-DE/de_DE.aff2
-rw-r--r--editor/spellchecker/tests/de-DE/de_DE.dic6
-rw-r--r--editor/spellchecker/tests/en-AU/en_AU.aff2
-rw-r--r--editor/spellchecker/tests/en-AU/en_AU.dic4
-rw-r--r--editor/spellchecker/tests/en-GB/en_GB.aff2
-rw-r--r--editor/spellchecker/tests/en-GB/en_GB.dic4
-rw-r--r--editor/spellchecker/tests/mochitest.toml104
-rw-r--r--editor/spellchecker/tests/multiple_content_languages_subframe.html13
-rw-r--r--editor/spellchecker/tests/ru-RU/ru_RU.aff1
-rw-r--r--editor/spellchecker/tests/ru-RU/ru_RU.dic2
-rw-r--r--editor/spellchecker/tests/spellcheck.js36
-rw-r--r--editor/spellchecker/tests/test_async_UpdateCurrentDictionary.html60
-rw-r--r--editor/spellchecker/tests/test_bug1100966.html74
-rw-r--r--editor/spellchecker/tests/test_bug1154791.html74
-rw-r--r--editor/spellchecker/tests/test_bug1200533.html163
-rw-r--r--editor/spellchecker/tests/test_bug1204147.html115
-rw-r--r--editor/spellchecker/tests/test_bug1205983.html134
-rw-r--r--editor/spellchecker/tests/test_bug1209414.html144
-rw-r--r--editor/spellchecker/tests/test_bug1219928.html69
-rw-r--r--editor/spellchecker/tests/test_bug1365383.html46
-rw-r--r--editor/spellchecker/tests/test_bug1368544.html91
-rw-r--r--editor/spellchecker/tests/test_bug1402822.html114
-rw-r--r--editor/spellchecker/tests/test_bug1418629.html210
-rw-r--r--editor/spellchecker/tests/test_bug1497480.html94
-rw-r--r--editor/spellchecker/tests/test_bug1602526.html57
-rw-r--r--editor/spellchecker/tests/test_bug1761273.html96
-rw-r--r--editor/spellchecker/tests/test_bug1773802.html96
-rw-r--r--editor/spellchecker/tests/test_bug1837268.html79
-rw-r--r--editor/spellchecker/tests/test_bug338427.html61
-rw-r--r--editor/spellchecker/tests/test_bug366682.html65
-rw-r--r--editor/spellchecker/tests/test_bug432225.html72
-rw-r--r--editor/spellchecker/tests/test_bug484181.html73
-rw-r--r--editor/spellchecker/tests/test_bug596333.html135
-rw-r--r--editor/spellchecker/tests/test_bug636465.html54
-rw-r--r--editor/spellchecker/tests/test_bug678842.html106
-rw-r--r--editor/spellchecker/tests/test_bug697981.html137
-rw-r--r--editor/spellchecker/tests/test_bug717433.html111
-rw-r--r--editor/spellchecker/tests/test_multiple_content_languages.html176
-rw-r--r--editor/spellchecker/tests/test_nsIEditorSpellCheck_ReplaceWord.html64
-rw-r--r--editor/spellchecker/tests/test_spellcheck_after_edit.html194
-rw-r--r--editor/spellchecker/tests/test_spellcheck_after_pressing_navigation_key.html77
-rw-r--r--editor/spellchecker/tests/test_spellcheck_selection.html36
-rw-r--r--editor/spellchecker/tests/test_suggest.html42
59 files changed, 8521 insertions, 0 deletions
diff --git a/editor/spellchecker/EditorSpellCheck.cpp b/editor/spellchecker/EditorSpellCheck.cpp
new file mode 100644
index 0000000000..cc5fcab9aa
--- /dev/null
+++ b/editor/spellchecker/EditorSpellCheck.cpp
@@ -0,0 +1,1183 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=2 sts=2 sw=2 tw=80: */
+/* 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/. */
+
+#include "EditorSpellCheck.h"
+
+#include "EditorBase.h" // for EditorBase
+#include "HTMLEditor.h" // for HTMLEditor
+#include "TextServicesDocument.h" // for TextServicesDocument
+
+#include "mozilla/Attributes.h" // for final
+#include "mozilla/dom/Element.h" // for Element
+#include "mozilla/dom/Promise.h"
+#include "mozilla/dom/Selection.h"
+#include "mozilla/dom/StaticRange.h"
+#include "mozilla/intl/Locale.h" // for mozilla::intl::Locale
+#include "mozilla/intl/LocaleService.h" // for retrieving app locale
+#include "mozilla/intl/OSPreferences.h" // for mozilla::intl::OSPreferences
+#include "mozilla/Logging.h" // for mozilla::LazyLogModule
+#include "mozilla/mozalloc.h" // for operator delete, etc
+#include "mozilla/mozSpellChecker.h" // for mozSpellChecker
+#include "mozilla/Preferences.h" // for Preferences
+
+#include "nsAString.h" // for nsAString::IsEmpty, etc
+#include "nsComponentManagerUtils.h" // for do_CreateInstance
+#include "nsDebug.h" // for NS_ENSURE_TRUE, etc
+#include "nsDependentSubstring.h" // for Substring
+#include "nsError.h" // for NS_ERROR_NOT_INITIALIZED, etc
+#include "nsIContent.h" // for nsIContent
+#include "nsIContentPrefService2.h" // for nsIContentPrefService2, etc
+#include "mozilla/dom/Document.h" // for Document
+#include "nsIEditor.h" // for nsIEditor
+#include "nsILoadContext.h"
+#include "nsISupports.h" // for nsISupports
+#include "nsISupportsUtils.h" // for NS_ADDREF
+#include "nsIURI.h" // for nsIURI
+#include "nsThreadUtils.h" // for GetMainThreadSerialEventTarget
+#include "nsVariant.h" // for nsIWritableVariant, etc
+#include "nsLiteralString.h" // for NS_LITERAL_STRING, etc
+#include "nsRange.h"
+#include "nsReadableUtils.h" // for ToNewUnicode, EmptyString, etc
+#include "nsServiceManagerUtils.h" // for do_GetService
+#include "nsString.h" // for nsAutoString, nsString, etc
+#include "nsStringFwd.h" // for nsAFlatString
+#include "nsStyleUtil.h" // for nsStyleUtil
+#include "nsXULAppAPI.h" // for XRE_GetProcessType
+
+namespace mozilla {
+
+using namespace dom;
+using intl::LocaleService;
+using intl::OSPreferences;
+
+static mozilla::LazyLogModule sEditorSpellChecker("EditorSpellChecker");
+
+class UpdateDictionaryHolder {
+ private:
+ EditorSpellCheck* mSpellCheck;
+
+ public:
+ explicit UpdateDictionaryHolder(EditorSpellCheck* esc) : mSpellCheck(esc) {
+ if (mSpellCheck) {
+ mSpellCheck->BeginUpdateDictionary();
+ }
+ }
+
+ ~UpdateDictionaryHolder() {
+ if (mSpellCheck) {
+ mSpellCheck->EndUpdateDictionary();
+ }
+ }
+};
+
+#define CPS_PREF_NAME u"spellcheck.lang"_ns
+
+/**
+ * Gets the URI of aEditor's document.
+ */
+static nsIURI* GetDocumentURI(EditorBase* aEditor) {
+ MOZ_ASSERT(aEditor);
+
+ Document* doc = aEditor->AsEditorBase()->GetDocument();
+ if (NS_WARN_IF(!doc)) {
+ return nullptr;
+ }
+
+ return doc->GetDocumentURI();
+}
+
+static nsILoadContext* GetLoadContext(nsIEditor* aEditor) {
+ Document* doc = aEditor->AsEditorBase()->GetDocument();
+ if (NS_WARN_IF(!doc)) {
+ return nullptr;
+ }
+
+ return doc->GetLoadContext();
+}
+
+static nsCString DictionariesToString(
+ const nsTArray<nsCString>& aDictionaries) {
+ nsCString asString;
+ for (const auto& dictionary : aDictionaries) {
+ asString.Append(dictionary);
+ asString.Append(',');
+ }
+ return asString;
+}
+
+static void StringToDictionaries(const nsCString& aString,
+ nsTArray<nsCString>& aDictionaries) {
+ nsTArray<nsCString> asDictionaries;
+ for (const nsACString& token :
+ nsCCharSeparatedTokenizer(aString, ',').ToRange()) {
+ if (token.IsEmpty()) {
+ continue;
+ }
+ aDictionaries.AppendElement(token);
+ }
+}
+
+/**
+ * Fetches the dictionary stored in content prefs and maintains state during the
+ * fetch, which is asynchronous.
+ */
+class DictionaryFetcher final : public nsIContentPrefCallback2 {
+ public:
+ NS_DECL_ISUPPORTS
+
+ DictionaryFetcher(EditorSpellCheck* aSpellCheck,
+ nsIEditorSpellCheckCallback* aCallback, uint32_t aGroup)
+ : mCallback(aCallback), mGroup(aGroup), mSpellCheck(aSpellCheck) {}
+
+ NS_IMETHOD Fetch(nsIEditor* aEditor);
+
+ NS_IMETHOD HandleResult(nsIContentPref* aPref) override {
+ nsCOMPtr<nsIVariant> value;
+ nsresult rv = aPref->GetValue(getter_AddRefs(value));
+ NS_ENSURE_SUCCESS(rv, rv);
+ nsCString asString;
+ value->GetAsACString(asString);
+ StringToDictionaries(asString, mDictionaries);
+ return NS_OK;
+ }
+
+ NS_IMETHOD HandleCompletion(uint16_t reason) override {
+ mSpellCheck->DictionaryFetched(this);
+ return NS_OK;
+ }
+
+ NS_IMETHOD HandleError(nsresult error) override { return NS_OK; }
+
+ nsCOMPtr<nsIEditorSpellCheckCallback> mCallback;
+ uint32_t mGroup;
+ RefPtr<nsAtom> mRootContentLang;
+ RefPtr<nsAtom> mRootDocContentLang;
+ nsTArray<nsCString> mDictionaries;
+
+ private:
+ ~DictionaryFetcher() {}
+
+ RefPtr<EditorSpellCheck> mSpellCheck;
+};
+
+NS_IMPL_ISUPPORTS(DictionaryFetcher, nsIContentPrefCallback2)
+
+class ContentPrefInitializerRunnable final : public Runnable {
+ public:
+ ContentPrefInitializerRunnable(nsIEditor* aEditor,
+ nsIContentPrefCallback2* aCallback)
+ : Runnable("ContentPrefInitializerRunnable"),
+ mEditorBase(aEditor->AsEditorBase()),
+ mCallback(aCallback) {}
+
+ NS_IMETHOD Run() override {
+ if (mEditorBase->Destroyed()) {
+ mCallback->HandleError(NS_ERROR_NOT_AVAILABLE);
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIContentPrefService2> contentPrefService =
+ do_GetService(NS_CONTENT_PREF_SERVICE_CONTRACTID);
+ if (NS_WARN_IF(!contentPrefService)) {
+ mCallback->HandleError(NS_ERROR_NOT_AVAILABLE);
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIURI> docUri = GetDocumentURI(mEditorBase);
+ if (NS_WARN_IF(!docUri)) {
+ mCallback->HandleError(NS_ERROR_FAILURE);
+ return NS_OK;
+ }
+
+ nsAutoCString docUriSpec;
+ nsresult rv = docUri->GetSpec(docUriSpec);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ mCallback->HandleError(rv);
+ return NS_OK;
+ }
+
+ rv = contentPrefService->GetByDomainAndName(
+ NS_ConvertUTF8toUTF16(docUriSpec), CPS_PREF_NAME,
+ GetLoadContext(mEditorBase), mCallback);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ mCallback->HandleError(rv);
+ return NS_OK;
+ }
+ return NS_OK;
+ }
+
+ private:
+ RefPtr<EditorBase> mEditorBase;
+ nsCOMPtr<nsIContentPrefCallback2> mCallback;
+};
+
+NS_IMETHODIMP
+DictionaryFetcher::Fetch(nsIEditor* aEditor) {
+ NS_ENSURE_ARG_POINTER(aEditor);
+
+ nsCOMPtr<nsIRunnable> runnable =
+ new ContentPrefInitializerRunnable(aEditor, this);
+ NS_DispatchToCurrentThreadQueue(runnable.forget(), 1000,
+ EventQueuePriority::Idle);
+
+ return NS_OK;
+}
+
+/**
+ * Stores the current dictionary for aEditor's document URL.
+ */
+static nsresult StoreCurrentDictionaries(
+ EditorBase* aEditorBase, const nsTArray<nsCString>& aDictionaries) {
+ NS_ENSURE_ARG_POINTER(aEditorBase);
+
+ nsresult rv;
+
+ nsCOMPtr<nsIURI> docUri = GetDocumentURI(aEditorBase);
+ if (NS_WARN_IF(!docUri)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ nsAutoCString docUriSpec;
+ rv = docUri->GetSpec(docUriSpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ RefPtr<nsVariant> prefValue = new nsVariant();
+
+ nsCString asString = DictionariesToString(aDictionaries);
+ prefValue->SetAsAString(NS_ConvertUTF8toUTF16(asString));
+
+ nsCOMPtr<nsIContentPrefService2> contentPrefService =
+ do_GetService(NS_CONTENT_PREF_SERVICE_CONTRACTID);
+ NS_ENSURE_TRUE(contentPrefService, NS_ERROR_NOT_INITIALIZED);
+
+ return contentPrefService->Set(NS_ConvertUTF8toUTF16(docUriSpec),
+ CPS_PREF_NAME, prefValue,
+ GetLoadContext(aEditorBase), nullptr);
+}
+
+/**
+ * Forgets the current dictionary stored for aEditor's document URL.
+ */
+static nsresult ClearCurrentDictionaries(EditorBase* aEditorBase) {
+ NS_ENSURE_ARG_POINTER(aEditorBase);
+
+ nsresult rv;
+
+ nsCOMPtr<nsIURI> docUri = GetDocumentURI(aEditorBase);
+ if (NS_WARN_IF(!docUri)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ nsAutoCString docUriSpec;
+ rv = docUri->GetSpec(docUriSpec);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsCOMPtr<nsIContentPrefService2> contentPrefService =
+ do_GetService(NS_CONTENT_PREF_SERVICE_CONTRACTID);
+ NS_ENSURE_TRUE(contentPrefService, NS_ERROR_NOT_INITIALIZED);
+
+ return contentPrefService->RemoveByDomainAndName(
+ NS_ConvertUTF8toUTF16(docUriSpec), CPS_PREF_NAME,
+ GetLoadContext(aEditorBase), nullptr);
+}
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(EditorSpellCheck)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(EditorSpellCheck)
+
+NS_INTERFACE_MAP_BEGIN(EditorSpellCheck)
+ NS_INTERFACE_MAP_ENTRY(nsIEditorSpellCheck)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIEditorSpellCheck)
+ NS_INTERFACE_MAP_ENTRIES_CYCLE_COLLECTION(EditorSpellCheck)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTION(EditorSpellCheck, mEditor, mSpellChecker)
+
+EditorSpellCheck::EditorSpellCheck()
+ : mTxtSrvFilterType(0),
+ mSuggestedWordIndex(0),
+ mDictionaryIndex(0),
+ mDictionaryFetcherGroup(0),
+ mUpdateDictionaryRunning(false) {}
+
+EditorSpellCheck::~EditorSpellCheck() {
+ // Make sure we blow the spellchecker away, just in
+ // case it hasn't been destroyed already.
+ mSpellChecker = nullptr;
+}
+
+mozSpellChecker* EditorSpellCheck::GetSpellChecker() { return mSpellChecker; }
+
+// The problem is that if the spell checker does not exist, we can not tell
+// which dictionaries are installed. This function works around the problem,
+// allowing callers to ask if we can spell check without actually doing so (and
+// enabling or disabling UI as necessary). This just creates a spellcheck
+// object if needed and asks it for the dictionary list.
+NS_IMETHODIMP
+EditorSpellCheck::CanSpellCheck(bool* aCanSpellCheck) {
+ RefPtr<mozSpellChecker> spellChecker = mSpellChecker;
+ if (!spellChecker) {
+ spellChecker = mozSpellChecker::Create();
+ MOZ_ASSERT(spellChecker);
+ }
+ nsTArray<nsCString> dictList;
+ nsresult rv = spellChecker->GetDictionaryList(&dictList);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ *aCanSpellCheck = !dictList.IsEmpty();
+ return NS_OK;
+}
+
+// Instances of this class can be used as either runnables or RAII helpers.
+class CallbackCaller final : public Runnable {
+ public:
+ explicit CallbackCaller(nsIEditorSpellCheckCallback* aCallback)
+ : mozilla::Runnable("CallbackCaller"), mCallback(aCallback) {}
+
+ ~CallbackCaller() { Run(); }
+
+ NS_IMETHOD Run() override {
+ if (mCallback) {
+ mCallback->EditorSpellCheckDone();
+ mCallback = nullptr;
+ }
+ return NS_OK;
+ }
+
+ private:
+ nsCOMPtr<nsIEditorSpellCheckCallback> mCallback;
+};
+
+NS_IMETHODIMP
+EditorSpellCheck::InitSpellChecker(nsIEditor* aEditor,
+ bool aEnableSelectionChecking,
+ nsIEditorSpellCheckCallback* aCallback) {
+ NS_ENSURE_TRUE(aEditor, NS_ERROR_NULL_POINTER);
+ mEditor = aEditor->AsEditorBase();
+
+ RefPtr<Document> doc = mEditor->GetDocument();
+ if (NS_WARN_IF(!doc)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ nsresult rv;
+
+ // We can spell check with any editor type
+ RefPtr<TextServicesDocument> textServicesDocument =
+ new TextServicesDocument();
+ textServicesDocument->SetFilterType(mTxtSrvFilterType);
+
+ // EditorBase::AddEditActionListener() needs to access mSpellChecker and
+ // mSpellChecker->GetTextServicesDocument(). Therefore, we need to
+ // initialize them before calling TextServicesDocument::InitWithEditor()
+ // since it calls EditorBase::AddEditActionListener().
+ mSpellChecker = mozSpellChecker::Create();
+ MOZ_ASSERT(mSpellChecker);
+ rv = mSpellChecker->SetDocument(textServicesDocument, true);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ // Pass the editor to the text services document
+ rv = textServicesDocument->InitWithEditor(aEditor);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ if (aEnableSelectionChecking) {
+ // Find out if the section is collapsed or not.
+ // If it isn't, we want to spellcheck just the selection.
+
+ RefPtr<Selection> selection;
+ aEditor->GetSelection(getter_AddRefs(selection));
+ if (NS_WARN_IF(!selection)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ if (selection->RangeCount()) {
+ RefPtr<const nsRange> range = selection->GetRangeAt(0);
+ NS_ENSURE_STATE(range);
+
+ if (!range->Collapsed()) {
+ // We don't want to touch the range in the selection,
+ // so create a new copy of it.
+ RefPtr<StaticRange> staticRange =
+ StaticRange::Create(range, IgnoreErrors());
+ if (NS_WARN_IF(!staticRange)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // Make sure the new range spans complete words.
+ rv = textServicesDocument->ExpandRangeToWordBoundaries(staticRange);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ // Now tell the text services that you only want
+ // to iterate over the text in this range.
+ rv = textServicesDocument->SetExtent(staticRange);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ }
+ }
+ }
+ // do not fail if UpdateCurrentDictionary fails because this method may
+ // succeed later.
+ rv = UpdateCurrentDictionary(aCallback);
+ if (NS_FAILED(rv) && aCallback) {
+ // However, if it does fail, we still need to call the callback since we
+ // discard the failure. Do it asynchronously so that the caller is always
+ // guaranteed async behavior.
+ RefPtr<CallbackCaller> caller = new CallbackCaller(aCallback);
+ rv = doc->Dispatch(caller.forget());
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::GetNextMisspelledWord(nsAString& aNextMisspelledWord) {
+ MOZ_LOG(sEditorSpellChecker, LogLevel::Debug, ("%s", __FUNCTION__));
+
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+
+ DeleteSuggestedWordList();
+ // Beware! This may flush notifications via synchronous
+ // ScrollSelectionIntoView.
+ RefPtr<mozSpellChecker> spellChecker(mSpellChecker);
+ return spellChecker->NextMisspelledWord(aNextMisspelledWord,
+ mSuggestedWordList);
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::GetSuggestedWord(nsAString& aSuggestedWord) {
+ // XXX This is buggy if mSuggestedWordList.Length() is over INT32_MAX.
+ if (mSuggestedWordIndex < static_cast<int32_t>(mSuggestedWordList.Length())) {
+ aSuggestedWord = mSuggestedWordList[mSuggestedWordIndex];
+ mSuggestedWordIndex++;
+ } else {
+ // A blank string signals that there are no more strings
+ aSuggestedWord.Truncate();
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::CheckCurrentWord(const nsAString& aSuggestedWord,
+ bool* aIsMisspelled) {
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+
+ DeleteSuggestedWordList();
+ return mSpellChecker->CheckWord(aSuggestedWord, aIsMisspelled,
+ &mSuggestedWordList);
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::Suggest(const nsAString& aSuggestedWord, uint32_t aCount,
+ JSContext* aCx, Promise** aPromise) {
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+
+ nsIGlobalObject* globalObject = xpc::CurrentNativeGlobal(aCx);
+ if (NS_WARN_IF(!globalObject)) {
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ ErrorResult result;
+ RefPtr<Promise> promise = Promise::Create(globalObject, result);
+ if (NS_WARN_IF(result.Failed())) {
+ return result.StealNSResult();
+ }
+
+ mSpellChecker->Suggest(aSuggestedWord, aCount)
+ ->Then(
+ GetMainThreadSerialEventTarget(), __func__,
+ [promise](const CopyableTArray<nsString>& aSuggestions) {
+ promise->MaybeResolve(aSuggestions);
+ },
+ [promise](nsresult aError) {
+ promise->MaybeReject(NS_ERROR_FAILURE);
+ });
+
+ promise.forget(aPromise);
+ return NS_OK;
+}
+
+RefPtr<CheckWordPromise> EditorSpellCheck::CheckCurrentWordsNoSuggest(
+ const nsTArray<nsString>& aSuggestedWords) {
+ if (NS_WARN_IF(!mSpellChecker)) {
+ return CheckWordPromise::CreateAndReject(NS_ERROR_NOT_INITIALIZED,
+ __func__);
+ }
+
+ return mSpellChecker->CheckWords(aSuggestedWords);
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::ReplaceWord(const nsAString& aMisspelledWord,
+ const nsAString& aReplaceWord,
+ bool aAllOccurrences) {
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+
+ RefPtr<mozSpellChecker> spellChecker(mSpellChecker);
+ return spellChecker->Replace(aMisspelledWord, aReplaceWord, aAllOccurrences);
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::IgnoreWordAllOccurrences(const nsAString& aWord) {
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+
+ return mSpellChecker->IgnoreAll(aWord);
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::GetPersonalDictionary() {
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+
+ // We can spell check with any editor type
+ mDictionaryList.Clear();
+ mDictionaryIndex = 0;
+ return mSpellChecker->GetPersonalDictionary(&mDictionaryList);
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::GetPersonalDictionaryWord(nsAString& aDictionaryWord) {
+ // XXX This is buggy if mDictionaryList.Length() is over INT32_MAX.
+ if (mDictionaryIndex < static_cast<int32_t>(mDictionaryList.Length())) {
+ aDictionaryWord = mDictionaryList[mDictionaryIndex];
+ mDictionaryIndex++;
+ } else {
+ // A blank string signals that there are no more strings
+ aDictionaryWord.Truncate();
+ }
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::AddWordToDictionary(const nsAString& aWord) {
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+
+ return mSpellChecker->AddWordToPersonalDictionary(aWord);
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::RemoveWordFromDictionary(const nsAString& aWord) {
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+
+ return mSpellChecker->RemoveWordFromPersonalDictionary(aWord);
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::GetDictionaryList(nsTArray<nsCString>& aList) {
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+
+ return mSpellChecker->GetDictionaryList(&aList);
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::GetCurrentDictionaries(nsTArray<nsCString>& aDictionaries) {
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+ return mSpellChecker->GetCurrentDictionaries(aDictionaries);
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::SetCurrentDictionaries(
+ const nsTArray<nsCString>& aDictionaries, JSContext* aCx,
+ Promise** aPromise) {
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+
+ RefPtr<EditorSpellCheck> kungFuDeathGrip = this;
+
+ // The purpose of mUpdateDictionaryRunning is to avoid doing all of this if
+ // UpdateCurrentDictionary's helper method DictionaryFetched, which calls us,
+ // is on the stack. In other words: Only do this, if the user manually
+ // selected a dictionary to use.
+ if (!mUpdateDictionaryRunning) {
+ // Ignore pending dictionary fetchers by increasing this number.
+ mDictionaryFetcherGroup++;
+
+ uint32_t flags = 0;
+ mEditor->GetFlags(&flags);
+ if (!(flags & nsIEditor::eEditorMailMask)) {
+ bool contentPrefMatchesUserPref = true;
+ // Check if aDictionaries has the same languages as mPreferredLangs.
+ if (!aDictionaries.IsEmpty()) {
+ if (aDictionaries.Length() != mPreferredLangs.Length()) {
+ contentPrefMatchesUserPref = false;
+ } else {
+ for (const auto& dictName : aDictionaries) {
+ if (mPreferredLangs.IndexOf(dictName) ==
+ nsTArray<nsCString>::NoIndex) {
+ contentPrefMatchesUserPref = false;
+ break;
+ }
+ }
+ }
+ }
+ if (!contentPrefMatchesUserPref) {
+ // When user sets dictionary manually, we store this value associated
+ // with editor url, if it doesn't match the document language exactly.
+ // For example on "en" sites, we need to store "en-GB", otherwise
+ // the language might jump back to en-US although the user explicitly
+ // chose otherwise.
+ StoreCurrentDictionaries(mEditor, aDictionaries);
+#ifdef DEBUG_DICT
+ printf("***** Writing content preferences for |%s|\n",
+ DictionariesToString(aDictionaries).Data());
+#endif
+ } else {
+ // If user sets a dictionary matching the language defined by
+ // document, we consider content pref has been canceled, and we clear
+ // it.
+ ClearCurrentDictionaries(mEditor);
+#ifdef DEBUG_DICT
+ printf("***** Clearing content preferences for |%s|\n",
+ DictionariesToString(aDictionaries).Data());
+#endif
+ }
+
+ // Also store it in as a preference, so we can use it as a fallback.
+ // We don't want this for mail composer because it uses
+ // "spellchecker.dictionary" as a preference.
+ //
+ // XXX: Prefs can only be set in the parent process, so this condition is
+ // necessary to stop libpref from throwing errors. But this should
+ // probably be handled in a better way.
+ if (XRE_IsParentProcess()) {
+ nsCString asString = DictionariesToString(aDictionaries);
+ Preferences::SetCString("spellchecker.dictionary", asString);
+#ifdef DEBUG_DICT
+ printf("***** Possibly storing spellchecker.dictionary |%s|\n",
+ asString.Data());
+#endif
+ }
+ } else {
+ MOZ_ASSERT(flags & nsIEditor::eEditorMailMask);
+ // Since the mail editor can only influence the language selection by the
+ // html lang attribute, set the content-language document to persist
+ // multi language selections.
+ // XXX Why doesn't here use the document of the editor directly?
+ nsCOMPtr<nsIContent> anonymousDivOrEditingHost;
+ if (HTMLEditor* htmlEditor = mEditor->GetAsHTMLEditor()) {
+ anonymousDivOrEditingHost = htmlEditor->ComputeEditingHost();
+ } else {
+ anonymousDivOrEditingHost = mEditor->GetRoot();
+ }
+ RefPtr<Document> ownerDoc = anonymousDivOrEditingHost->OwnerDoc();
+ Document* parentDoc = ownerDoc->GetInProcessParentDocument();
+ if (parentDoc) {
+ parentDoc->SetHeaderData(
+ nsGkAtoms::headerContentLanguage,
+ NS_ConvertUTF8toUTF16(DictionariesToString(aDictionaries)));
+ }
+ }
+ }
+
+ nsIGlobalObject* globalObject = xpc::CurrentNativeGlobal(aCx);
+ if (NS_WARN_IF(!globalObject)) {
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ ErrorResult result;
+ RefPtr<Promise> promise = Promise::Create(globalObject, result);
+ if (NS_WARN_IF(result.Failed())) {
+ return result.StealNSResult();
+ }
+
+ mSpellChecker->SetCurrentDictionaries(aDictionaries)
+ ->Then(
+ GetMainThreadSerialEventTarget(), __func__,
+ [promise]() { promise->MaybeResolveWithUndefined(); },
+ [promise](nsresult aError) {
+ promise->MaybeReject(NS_ERROR_FAILURE);
+ });
+
+ promise.forget(aPromise);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::UninitSpellChecker() {
+ NS_ENSURE_TRUE(mSpellChecker, NS_ERROR_NOT_INITIALIZED);
+
+ // Cleanup - kill the spell checker
+ DeleteSuggestedWordList();
+ mDictionaryList.Clear();
+ mDictionaryIndex = 0;
+ mDictionaryFetcherGroup++;
+ mSpellChecker = nullptr;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::SetFilterType(uint32_t aFilterType) {
+ mTxtSrvFilterType = aFilterType;
+ return NS_OK;
+}
+
+nsresult EditorSpellCheck::DeleteSuggestedWordList() {
+ mSuggestedWordList.Clear();
+ mSuggestedWordIndex = 0;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+EditorSpellCheck::UpdateCurrentDictionary(
+ nsIEditorSpellCheckCallback* aCallback) {
+ if (NS_WARN_IF(!mSpellChecker)) {
+ return NS_ERROR_NOT_INITIALIZED;
+ }
+
+ nsresult rv;
+
+ RefPtr<EditorSpellCheck> kungFuDeathGrip = this;
+
+ // Get language with html5 algorithm
+ const RefPtr<Element> rootEditableElement =
+ [](const EditorBase& aEditorBase) -> Element* {
+ if (!aEditorBase.IsHTMLEditor()) {
+ return aEditorBase.GetRoot();
+ }
+ if (aEditorBase.IsMailEditor()) {
+ // Shouldn't run spellcheck in a mail editor without focus
+ // (bug 1507543)
+ // XXX Why doesn't here use the document of the editor directly?
+ Element* const editingHost =
+ aEditorBase.AsHTMLEditor()->ComputeEditingHost();
+ if (!editingHost) {
+ return nullptr;
+ }
+ // Try to get topmost document's document element for embedded mail
+ // editor (bug 967494)
+ Document* parentDoc =
+ editingHost->OwnerDoc()->GetInProcessParentDocument();
+ if (!parentDoc) {
+ return editingHost;
+ }
+ return parentDoc->GetDocumentElement();
+ }
+ return aEditorBase.AsHTMLEditor()->GetFocusedElement();
+ }(*mEditor);
+
+ if (!rootEditableElement) {
+ return NS_ERROR_FAILURE;
+ }
+
+ RefPtr<DictionaryFetcher> fetcher =
+ new DictionaryFetcher(this, aCallback, mDictionaryFetcherGroup);
+ fetcher->mRootContentLang = rootEditableElement->GetLang();
+ RefPtr<Document> doc = rootEditableElement->GetComposedDoc();
+ NS_ENSURE_STATE(doc);
+ fetcher->mRootDocContentLang = doc->GetContentLanguage();
+
+ rv = fetcher->Fetch(mEditor);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+// Helper function that iterates over the list of dictionaries and sets the one
+// that matches based on a given comparison type.
+bool EditorSpellCheck::BuildDictionaryList(const nsACString& aDictName,
+ const nsTArray<nsCString>& aDictList,
+ enum dictCompare aCompareType,
+ nsTArray<nsCString>& aOutList) {
+ for (const auto& dictStr : aDictList) {
+ bool equals = false;
+ switch (aCompareType) {
+ case DICT_NORMAL_COMPARE:
+ equals = aDictName.Equals(dictStr);
+ break;
+ case DICT_COMPARE_CASE_INSENSITIVE:
+ equals = aDictName.Equals(dictStr, nsCaseInsensitiveCStringComparator);
+ break;
+ case DICT_COMPARE_DASHMATCH:
+ equals = nsStyleUtil::DashMatchCompare(
+ NS_ConvertUTF8toUTF16(dictStr), NS_ConvertUTF8toUTF16(aDictName),
+ nsCaseInsensitiveStringComparator);
+ break;
+ }
+ if (equals) {
+ // Avoid adding duplicates to aOutList.
+ if (aOutList.IndexOf(dictStr) == nsTArray<nsCString>::NoIndex) {
+ aOutList.AppendElement(dictStr);
+ }
+#ifdef DEBUG_DICT
+ printf("***** Trying |%s|.\n", dictStr.get());
+#endif
+ // We always break here. We tried to set the dictionary to an existing
+ // dictionary from the list. This must work, if it doesn't, there is
+ // no point trying another one.
+ return true;
+ }
+ }
+ return false;
+}
+
+nsresult EditorSpellCheck::DictionaryFetched(DictionaryFetcher* aFetcher) {
+ MOZ_ASSERT(aFetcher);
+ RefPtr<EditorSpellCheck> kungFuDeathGrip = this;
+
+ BeginUpdateDictionary();
+
+ if (aFetcher->mGroup < mDictionaryFetcherGroup) {
+ // SetCurrentDictionary was called after the fetch started. Don't overwrite
+ // that dictionary with the fetched one.
+ EndUpdateDictionary();
+ if (aFetcher->mCallback) {
+ aFetcher->mCallback->EditorSpellCheckDone();
+ }
+ return NS_OK;
+ }
+
+ /*
+ * We try to derive the dictionary to use based on the following priorities:
+ * 1) Content preference, so the language the user set for the site before.
+ * (Introduced in bug 678842 and corrected in bug 717433.)
+ * 2) Language set by the website, or any other dictionary that partly
+ * matches that. (Introduced in bug 338427.)
+ * Eg. if the website is "en-GB", a user who only has "en-US" will get
+ * that. If the website is generic "en", the user will get one of the
+ * "en-*" installed. If application locale or system locale is "en-*",
+ * we get it. If others, it is (almost) random.
+ * However, we prefer what is stored in "spellchecker.dictionary",
+ * so if the user chose "en-AU" before, they will get "en-AU" on a plain
+ * "en" site. (Introduced in bug 682564.)
+ * If the site has multiple languages declared in its Content-Language
+ * header and there is no more specific lang tag in HTML, we try to
+ * enable a dictionary for every content language.
+ * 3) The value of "spellchecker.dictionary" which reflects a previous
+ * language choice of the user (on another site).
+ * (This was the original behaviour before the aforementioned bugs
+ * landed).
+ * 4) The user's locale.
+ * 5) Use the current dictionary that is currently set.
+ * 6) The content of the "LANG" environment variable (if set).
+ * 7) The first spell check dictionary installed.
+ */
+
+ // Get the language from the element or its closest parent according to:
+ // https://html.spec.whatwg.org/#attr-lang
+ // This is used in SetCurrentDictionaries.
+ nsCString contentLangs;
+ // Reset mPreferredLangs so we only get the current state.
+ mPreferredLangs.Clear();
+ if (aFetcher->mRootContentLang) {
+ aFetcher->mRootContentLang->ToUTF8String(contentLangs);
+ }
+#ifdef DEBUG_DICT
+ printf("***** mPreferredLangs (element) |%s|\n", contentLangs.get());
+#endif
+ if (!contentLangs.IsEmpty()) {
+ mPreferredLangs.AppendElement(contentLangs);
+ } else {
+ // If no luck, try the "Content-Language" header.
+ if (aFetcher->mRootDocContentLang) {
+ aFetcher->mRootDocContentLang->ToUTF8String(contentLangs);
+ }
+#ifdef DEBUG_DICT
+ printf("***** mPreferredLangs (content-language) |%s|\n",
+ contentLangs.get());
+#endif
+ StringToDictionaries(contentLangs, mPreferredLangs);
+ }
+
+ // We obtain a list of available dictionaries.
+ AutoTArray<nsCString, 8> dictList;
+ nsresult rv = mSpellChecker->GetDictionaryList(&dictList);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ EndUpdateDictionary();
+ if (aFetcher->mCallback) {
+ aFetcher->mCallback->EditorSpellCheckDone();
+ }
+ return rv;
+ }
+
+ // Priority 1:
+ // If we successfully fetched a dictionary from content prefs, do not go
+ // further. Use this exact dictionary.
+ // Don't use content preferences for editor with eEditorMailMask flag.
+ nsAutoCString dictName;
+ uint32_t flags;
+ mEditor->GetFlags(&flags);
+ if (!(flags & nsIEditor::eEditorMailMask)) {
+ if (!aFetcher->mDictionaries.IsEmpty()) {
+ RefPtr<EditorSpellCheck> self = this;
+ RefPtr<DictionaryFetcher> fetcher = aFetcher;
+ mSpellChecker->SetCurrentDictionaries(aFetcher->mDictionaries)
+ ->Then(
+ GetMainThreadSerialEventTarget(), __func__,
+ [self, fetcher]() {
+#ifdef DEBUG_DICT
+ printf("***** Assigned from content preferences |%s|\n",
+ DictionariesToString(fetcher->mDictionaries).Data());
+#endif
+ // We take an early exit here, so let's not forget to clear
+ // the word list.
+ self->DeleteSuggestedWordList();
+
+ self->EndUpdateDictionary();
+ if (fetcher->mCallback) {
+ fetcher->mCallback->EditorSpellCheckDone();
+ }
+ },
+ [self, fetcher](nsresult aError) {
+ if (aError == NS_ERROR_ABORT) {
+ return;
+ }
+ // May be dictionary was uninstalled ?
+ // Clear the content preference and continue.
+ ClearCurrentDictionaries(self->mEditor);
+
+ // Priority 2 or later will handled by the following
+ self->SetFallbackDictionary(fetcher);
+ });
+ return NS_OK;
+ }
+ }
+ SetFallbackDictionary(aFetcher);
+ return NS_OK;
+}
+
+void EditorSpellCheck::SetDictionarySucceeded(DictionaryFetcher* aFetcher) {
+ DeleteSuggestedWordList();
+ EndUpdateDictionary();
+ if (aFetcher->mCallback) {
+ aFetcher->mCallback->EditorSpellCheckDone();
+ }
+}
+
+void EditorSpellCheck::SetFallbackDictionary(DictionaryFetcher* aFetcher) {
+ MOZ_ASSERT(mUpdateDictionaryRunning);
+
+ AutoTArray<nsCString, 6> tryDictList;
+
+ // We obtain a list of available dictionaries.
+ AutoTArray<nsCString, 8> dictList;
+ nsresult rv = mSpellChecker->GetDictionaryList(&dictList);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ EndUpdateDictionary();
+ if (aFetcher->mCallback) {
+ aFetcher->mCallback->EditorSpellCheckDone();
+ }
+ return;
+ }
+
+ // Priority 2:
+ // After checking the content preferences, we use the languages of the element
+ // or document.
+
+ // Get the preference value.
+ nsAutoCString prefDictionariesAsString;
+ Preferences::GetLocalizedCString("spellchecker.dictionary",
+ prefDictionariesAsString);
+ nsTArray<nsCString> prefDictionaries;
+ StringToDictionaries(prefDictionariesAsString, prefDictionaries);
+
+ nsAutoCString appLocaleStr;
+ // We pick one dictionary for every language that the element or document
+ // indicates it contains.
+ for (const auto& dictName : mPreferredLangs) {
+ // RFC 5646 explicitly states that matches should be case-insensitive.
+ if (BuildDictionaryList(dictName, dictList, DICT_COMPARE_CASE_INSENSITIVE,
+ tryDictList)) {
+#ifdef DEBUG_DICT
+ printf("***** Trying from element/doc |%s| \n", dictName.get());
+#endif
+ continue;
+ }
+
+ // Required dictionary was not available. Try to get a dictionary
+ // matching at least language part of dictName.
+ mozilla::intl::Locale loc;
+ if (mozilla::intl::LocaleParser::TryParse(dictName, loc).isOk() &&
+ loc.Canonicalize().isOk()) {
+ Span<const char> language = loc.Language().Span();
+ nsAutoCString langCode(language.data(), language.size());
+
+ // Try dictionary.spellchecker preference, if it starts with langCode,
+ // so we don't just get any random dictionary matching the language.
+ bool didAppend = false;
+ for (const auto& dictionary : prefDictionaries) {
+ if (nsStyleUtil::DashMatchCompare(NS_ConvertUTF8toUTF16(dictionary),
+ NS_ConvertUTF8toUTF16(langCode),
+ nsTDefaultStringComparator)) {
+#ifdef DEBUG_DICT
+ printf(
+ "***** Trying preference value |%s| since it matches language "
+ "code\n",
+ dictionary.Data());
+#endif
+ if (BuildDictionaryList(dictionary, dictList,
+ DICT_COMPARE_CASE_INSENSITIVE, tryDictList)) {
+ didAppend = true;
+ break;
+ }
+ }
+ }
+ if (didAppend) {
+ continue;
+ }
+
+ // Use the application locale dictionary when the required language
+ // equals applocation locale language.
+ LocaleService::GetInstance()->GetAppLocaleAsBCP47(appLocaleStr);
+ if (!appLocaleStr.IsEmpty()) {
+ mozilla::intl::Locale appLoc;
+ auto result =
+ mozilla::intl::LocaleParser::TryParse(appLocaleStr, appLoc);
+ if (result.isOk() && appLoc.Canonicalize().isOk() &&
+ loc.Language().Span() == appLoc.Language().Span()) {
+ if (BuildDictionaryList(appLocaleStr, dictList,
+ DICT_COMPARE_CASE_INSENSITIVE, tryDictList)) {
+ continue;
+ }
+ }
+ }
+
+ // Use the system locale dictionary when the required language equlas
+ // system locale language.
+ nsAutoCString sysLocaleStr;
+ OSPreferences::GetInstance()->GetSystemLocale(sysLocaleStr);
+ if (!sysLocaleStr.IsEmpty()) {
+ mozilla::intl::Locale sysLoc;
+ auto result =
+ mozilla::intl::LocaleParser::TryParse(sysLocaleStr, sysLoc);
+ if (result.isOk() && sysLoc.Canonicalize().isOk() &&
+ loc.Language().Span() == sysLoc.Language().Span()) {
+ if (BuildDictionaryList(sysLocaleStr, dictList,
+ DICT_COMPARE_CASE_INSENSITIVE, tryDictList)) {
+ continue;
+ }
+ }
+ }
+
+ // Use any dictionary with the required language.
+#ifdef DEBUG_DICT
+ printf("***** Trying to find match for language code |%s|\n",
+ langCode.get());
+#endif
+ BuildDictionaryList(langCode, dictList, DICT_COMPARE_DASHMATCH,
+ tryDictList);
+ }
+ }
+
+ RefPtr<EditorSpellCheck> self = this;
+ RefPtr<DictionaryFetcher> fetcher = aFetcher;
+ RefPtr<GenericPromise> promise;
+
+ if (tryDictList.IsEmpty()) {
+ // Proceed to priority 3 if the list of dictionaries is empty.
+ promise = GenericPromise::CreateAndReject(NS_ERROR_INVALID_ARG, __func__);
+ } else {
+ promise = mSpellChecker->SetCurrentDictionaries(tryDictList);
+ }
+
+ // If an error was thrown while setting the dictionary, just
+ // fail silently so that the spellchecker dialog is allowed to come
+ // up. The user can manually reset the language to their choice on
+ // the dialog if it is wrong.
+ promise->Then(
+ GetMainThreadSerialEventTarget(), __func__,
+ [self, fetcher]() { self->SetDictionarySucceeded(fetcher); },
+ [prefDictionaries = prefDictionaries.Clone(), dictList = dictList.Clone(),
+ self, fetcher]() {
+ // Build tryDictList with dictionaries for priorities 4 through 7.
+ // We'll use this list if there is no user preference or trying
+ // the user preference fails.
+ AutoTArray<nsCString, 6> tryDictList;
+
+ // Priority 4:
+ // As next fallback, try the current locale.
+ nsAutoCString appLocaleStr;
+ LocaleService::GetInstance()->GetAppLocaleAsBCP47(appLocaleStr);
+#ifdef DEBUG_DICT
+ printf("***** Trying locale |%s|\n", appLocaleStr.get());
+#endif
+ self->BuildDictionaryList(appLocaleStr, dictList,
+ DICT_COMPARE_CASE_INSENSITIVE, tryDictList);
+
+ // Priority 5:
+ // If we have a current dictionary and we don't have no item in try
+ // list, don't try anything else.
+ nsTArray<nsCString> currentDictionaries;
+ self->GetCurrentDictionaries(currentDictionaries);
+ if (!currentDictionaries.IsEmpty() && tryDictList.IsEmpty()) {
+#ifdef DEBUG_DICT
+ printf("***** Retrieved current dict |%s|\n",
+ DictionariesToString(currentDictionaries).Data());
+#endif
+ self->EndUpdateDictionary();
+ if (fetcher->mCallback) {
+ fetcher->mCallback->EditorSpellCheckDone();
+ }
+ return;
+ }
+
+ // Priority 6:
+ // Try to get current dictionary from environment variable LANG.
+ // LANG = language[_territory][.charset]
+ char* env_lang = getenv("LANG");
+ if (env_lang) {
+ nsAutoCString lang(env_lang);
+ // Strip trailing charset, if there is any.
+ int32_t dot_pos = lang.FindChar('.');
+ if (dot_pos != -1) {
+ lang = Substring(lang, 0, dot_pos);
+ }
+
+ int32_t underScore = lang.FindChar('_');
+ if (underScore != -1) {
+ lang.Replace(underScore, 1, '-');
+#ifdef DEBUG_DICT
+ printf("***** Trying LANG from environment |%s|\n", lang.get());
+#endif
+ self->BuildDictionaryList(
+ lang, dictList, DICT_COMPARE_CASE_INSENSITIVE, tryDictList);
+ }
+ }
+
+ // Priority 7:
+ // If it does not work, pick the first one.
+ if (!dictList.IsEmpty()) {
+ self->BuildDictionaryList(dictList[0], dictList, DICT_NORMAL_COMPARE,
+ tryDictList);
+#ifdef DEBUG_DICT
+ printf("***** Trying first of list |%s|\n", dictList[0].get());
+#endif
+ }
+
+ // Priority 3:
+ // If the document didn't supply a dictionary or the setting
+ // failed, try the user preference next.
+ if (!prefDictionaries.IsEmpty()) {
+ self->mSpellChecker->SetCurrentDictionaries(prefDictionaries)
+ ->Then(
+ GetMainThreadSerialEventTarget(), __func__,
+ [self, fetcher]() { self->SetDictionarySucceeded(fetcher); },
+ // Priority 3 failed, we'll use the list we built of
+ // priorities 4 to 7.
+ [tryDictList = tryDictList.Clone(), self, fetcher]() {
+ self->mSpellChecker
+ ->SetCurrentDictionaryFromList(tryDictList)
+ ->Then(GetMainThreadSerialEventTarget(), __func__,
+ [self, fetcher]() {
+ self->SetDictionarySucceeded(fetcher);
+ });
+ });
+ } else {
+ // We don't have a user preference, so we'll try the list we
+ // built of priorities 4 to 7.
+ self->mSpellChecker->SetCurrentDictionaryFromList(tryDictList)
+ ->Then(
+ GetMainThreadSerialEventTarget(), __func__,
+ [self, fetcher]() { self->SetDictionarySucceeded(fetcher); });
+ }
+ });
+}
+
+} // namespace mozilla
diff --git a/editor/spellchecker/EditorSpellCheck.h b/editor/spellchecker/EditorSpellCheck.h
new file mode 100644
index 0000000000..d9de5d9d40
--- /dev/null
+++ b/editor/spellchecker/EditorSpellCheck.h
@@ -0,0 +1,99 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef mozilla_EditorSpellCheck_h
+#define mozilla_EditorSpellCheck_h
+
+#include "mozilla/mozSpellChecker.h" // for mozilla::CheckWordPromise
+#include "nsCOMPtr.h" // for nsCOMPtr
+#include "nsCycleCollectionParticipant.h"
+#include "nsIEditorSpellCheck.h" // for NS_DECL_NSIEDITORSPELLCHECK, etc
+#include "nsISupportsImpl.h"
+#include "nsString.h" // for nsString
+#include "nsTArray.h" // for nsTArray
+#include "nscore.h" // for nsresult
+
+class mozSpellChecker;
+class nsIEditor;
+
+namespace mozilla {
+
+class DictionaryFetcher;
+class EditorBase;
+
+enum dictCompare {
+ DICT_NORMAL_COMPARE,
+ DICT_COMPARE_CASE_INSENSITIVE,
+ DICT_COMPARE_DASHMATCH
+};
+
+class EditorSpellCheck final : public nsIEditorSpellCheck {
+ friend class DictionaryFetcher;
+
+ public:
+ EditorSpellCheck();
+
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS(EditorSpellCheck)
+
+ /* Declare all methods in the nsIEditorSpellCheck interface */
+ NS_DECL_NSIEDITORSPELLCHECK
+
+ mozSpellChecker* GetSpellChecker();
+
+ /**
+ * Like CheckCurrentWord, checks the word you give it, returning true via
+ * promise if it's misspelled.
+ * This is faster than CheckCurrentWord because it does not compute
+ * any suggestions.
+ *
+ * Watch out: this does not clear any suggestions left over from previous
+ * calls to CheckCurrentWord, so there may be suggestions, but they will be
+ * invalid.
+ */
+ RefPtr<mozilla::CheckWordPromise> CheckCurrentWordsNoSuggest(
+ const nsTArray<nsString>& aSuggestedWords);
+
+ protected:
+ virtual ~EditorSpellCheck();
+
+ RefPtr<mozSpellChecker> mSpellChecker;
+ RefPtr<EditorBase> mEditor;
+
+ nsTArray<nsString> mSuggestedWordList;
+
+ // these are the words in the current personal dictionary,
+ // GetPersonalDictionary must be called to load them.
+ nsTArray<nsString> mDictionaryList;
+
+ nsTArray<nsCString> mPreferredLangs;
+
+ uint32_t mTxtSrvFilterType;
+ int32_t mSuggestedWordIndex;
+ int32_t mDictionaryIndex;
+ uint32_t mDictionaryFetcherGroup;
+
+ bool mUpdateDictionaryRunning;
+
+ nsresult DeleteSuggestedWordList();
+
+ bool BuildDictionaryList(const nsACString& aDictName,
+ const nsTArray<nsCString>& aDictList,
+ enum dictCompare aCompareType,
+ nsTArray<nsCString>& aOutList);
+
+ nsresult DictionaryFetched(DictionaryFetcher* aFetchState);
+
+ void SetDictionarySucceeded(DictionaryFetcher* aFetcher);
+ void SetFallbackDictionary(DictionaryFetcher* aFetcher);
+
+ public:
+ void BeginUpdateDictionary() { mUpdateDictionaryRunning = true; }
+ void EndUpdateDictionary() { mUpdateDictionaryRunning = false; }
+};
+
+} // namespace mozilla
+
+#endif // mozilla_EditorSpellCheck_h
diff --git a/editor/spellchecker/FilteredContentIterator.cpp b/editor/spellchecker/FilteredContentIterator.cpp
new file mode 100644
index 0000000000..b0bfcd508a
--- /dev/null
+++ b/editor/spellchecker/FilteredContentIterator.cpp
@@ -0,0 +1,398 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "FilteredContentIterator.h"
+
+#include <utility>
+
+#include "mozilla/ContentIterator.h"
+#include "mozilla/dom/AbstractRange.h"
+#include "mozilla/Maybe.h"
+#include "mozilla/mozalloc.h"
+#include "nsAtom.h"
+#include "nsComponentManagerUtils.h"
+#include "nsComposeTxtSrvFilter.h"
+#include "nsContentUtils.h"
+#include "nsDebug.h"
+#include "nsError.h"
+#include "nsIContent.h"
+#include "nsINode.h"
+#include "nsISupports.h"
+#include "nsISupportsUtils.h"
+#include "nsRange.h"
+
+namespace mozilla {
+
+using namespace dom;
+
+FilteredContentIterator::FilteredContentIterator(
+ UniquePtr<nsComposeTxtSrvFilter> aFilter)
+ : mCurrentIterator(nullptr),
+ mFilter(std::move(aFilter)),
+ mDidSkip(false),
+ mIsOutOfRange(false),
+ mDirection(eDirNotSet) {}
+
+FilteredContentIterator::~FilteredContentIterator() {}
+
+NS_IMPL_CYCLE_COLLECTION(FilteredContentIterator, mPostIterator, mPreIterator,
+ mRange)
+
+nsresult FilteredContentIterator::Init(nsINode* aRoot) {
+ NS_ENSURE_ARG_POINTER(aRoot);
+ mIsOutOfRange = false;
+ mDirection = eForward;
+ mCurrentIterator = &mPreIterator;
+
+ mRange = nsRange::Create(aRoot);
+ mRange->SelectNode(*aRoot, IgnoreErrors());
+
+ nsresult rv = mPreIterator.Init(mRange);
+ NS_ENSURE_SUCCESS(rv, rv);
+ return mPostIterator.Init(mRange);
+}
+
+nsresult FilteredContentIterator::Init(const AbstractRange* aAbstractRange) {
+ if (NS_WARN_IF(!aAbstractRange)) {
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ if (NS_WARN_IF(!aAbstractRange->IsPositioned())) {
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ mRange = nsRange::Create(aAbstractRange, IgnoreErrors());
+ if (NS_WARN_IF(!mRange)) {
+ return NS_ERROR_FAILURE;
+ }
+ return InitWithRange();
+}
+
+nsresult FilteredContentIterator::Init(nsINode* aStartContainer,
+ uint32_t aStartOffset,
+ nsINode* aEndContainer,
+ uint32_t aEndOffset) {
+ return Init(RawRangeBoundary(aStartContainer, aStartOffset),
+ RawRangeBoundary(aEndContainer, aEndOffset));
+}
+
+nsresult FilteredContentIterator::Init(const RawRangeBoundary& aStartBoundary,
+ const RawRangeBoundary& aEndBoundary) {
+ RefPtr<nsRange> range =
+ nsRange::Create(aStartBoundary, aEndBoundary, IgnoreErrors());
+ if (NS_WARN_IF(!range) || NS_WARN_IF(!range->IsPositioned())) {
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ MOZ_ASSERT(range->StartRef() == aStartBoundary);
+ MOZ_ASSERT(range->EndRef() == aEndBoundary);
+
+ mRange = std::move(range);
+
+ return InitWithRange();
+}
+
+nsresult FilteredContentIterator::InitWithRange() {
+ MOZ_ASSERT(mRange);
+ MOZ_ASSERT(mRange->IsPositioned());
+
+ mIsOutOfRange = false;
+ mDirection = eForward;
+ mCurrentIterator = &mPreIterator;
+
+ nsresult rv = mPreIterator.Init(mRange);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ return mPostIterator.Init(mRange);
+}
+
+nsresult FilteredContentIterator::SwitchDirections(bool aChangeToForward) {
+ nsINode* node = mCurrentIterator->GetCurrentNode();
+
+ if (aChangeToForward) {
+ mCurrentIterator = &mPreIterator;
+ mDirection = eForward;
+ } else {
+ mCurrentIterator = &mPostIterator;
+ mDirection = eBackward;
+ }
+
+ if (node) {
+ nsresult rv = mCurrentIterator->PositionAt(node);
+ if (NS_FAILED(rv)) {
+ mIsOutOfRange = true;
+ return rv;
+ }
+ }
+ return NS_OK;
+}
+
+void FilteredContentIterator::First() {
+ if (!mCurrentIterator) {
+ NS_ERROR("Missing iterator!");
+
+ return;
+ }
+
+ // If we are switching directions then
+ // we need to switch how we process the nodes
+ if (mDirection != eForward) {
+ mCurrentIterator = &mPreIterator;
+ mDirection = eForward;
+ mIsOutOfRange = false;
+ }
+
+ mCurrentIterator->First();
+
+ if (mCurrentIterator->IsDone()) {
+ return;
+ }
+
+ nsINode* currentNode = mCurrentIterator->GetCurrentNode();
+
+ bool didCross;
+ CheckAdvNode(currentNode, didCross, eForward);
+}
+
+void FilteredContentIterator::Last() {
+ if (!mCurrentIterator) {
+ NS_ERROR("Missing iterator!");
+
+ return;
+ }
+
+ // If we are switching directions then
+ // we need to switch how we process the nodes
+ if (mDirection != eBackward) {
+ mCurrentIterator = &mPostIterator;
+ mDirection = eBackward;
+ mIsOutOfRange = false;
+ }
+
+ mCurrentIterator->Last();
+
+ if (mCurrentIterator->IsDone()) {
+ return;
+ }
+
+ nsINode* currentNode = mCurrentIterator->GetCurrentNode();
+
+ bool didCross;
+ CheckAdvNode(currentNode, didCross, eBackward);
+}
+
+///////////////////////////////////////////////////////////////////////////
+// ContentIsInTraversalRange: returns true if content is visited during
+// the traversal of the range in the specified mode.
+//
+static bool ContentIsInTraversalRange(nsIContent* aContent, bool aIsPreMode,
+ nsINode* aStartContainer,
+ int32_t aStartOffset,
+ nsINode* aEndContainer,
+ int32_t aEndOffset) {
+ NS_ENSURE_TRUE(aStartContainer && aEndContainer && aContent, false);
+
+ nsIContent* parentContent = aContent->GetParent();
+ if (MOZ_UNLIKELY(NS_WARN_IF(!parentContent))) {
+ return false;
+ }
+ Maybe<uint32_t> offsetInParent = parentContent->ComputeIndexOf(aContent);
+ NS_WARNING_ASSERTION(
+ offsetInParent.isSome(),
+ "Content is not in the parent, is this called during a DOM mutation?");
+ if (MOZ_UNLIKELY(NS_WARN_IF(offsetInParent.isNothing()))) {
+ return false;
+ }
+
+ if (!aIsPreMode) {
+ MOZ_ASSERT(*offsetInParent != UINT32_MAX);
+ ++(*offsetInParent);
+ }
+
+ const Maybe<int32_t> startRes = nsContentUtils::ComparePoints(
+ aStartContainer, aStartOffset, parentContent, *offsetInParent);
+ if (MOZ_UNLIKELY(NS_WARN_IF(!startRes))) {
+ return false;
+ }
+ const Maybe<int32_t> endRes = nsContentUtils::ComparePoints(
+ aEndContainer, aEndOffset, parentContent, *offsetInParent);
+ if (MOZ_UNLIKELY(NS_WARN_IF(!endRes))) {
+ return false;
+ }
+ return *startRes <= 0 && *endRes >= 0;
+}
+
+static bool ContentIsInTraversalRange(nsRange* aRange, nsIContent* aNextContent,
+ bool aIsPreMode) {
+ // XXXbz we have a caller below (in AdvanceNode) who passes null for
+ // aNextContent!
+ NS_ENSURE_TRUE(aNextContent && aRange, false);
+
+ return ContentIsInTraversalRange(
+ aNextContent, aIsPreMode, aRange->GetStartContainer(),
+ static_cast<int32_t>(aRange->StartOffset()), aRange->GetEndContainer(),
+ static_cast<int32_t>(aRange->EndOffset()));
+}
+
+// Helper function to advance to the next or previous node
+nsresult FilteredContentIterator::AdvanceNode(nsINode* aNode,
+ nsINode*& aNewNode,
+ eDirectionType aDir) {
+ nsCOMPtr<nsIContent> nextNode;
+ if (aDir == eForward) {
+ nextNode = aNode->GetNextSibling();
+ } else {
+ nextNode = aNode->GetPreviousSibling();
+ }
+
+ if (nextNode) {
+ // If we got here, that means we found the nxt/prv node
+ // make sure it is in our DOMRange
+ bool intersects =
+ ContentIsInTraversalRange(mRange, nextNode, aDir == eForward);
+ if (intersects) {
+ aNewNode = nextNode;
+ NS_ADDREF(aNewNode);
+ return NS_OK;
+ }
+ } else {
+ // The next node was null so we need to walk up the parent(s)
+ nsCOMPtr<nsINode> parent = aNode->GetParentNode();
+ NS_ASSERTION(parent, "parent can't be nullptr");
+
+ // Make sure the parent is in the DOMRange before going further
+ // XXXbz why are we passing nextNode, not the parent??? If this gets fixed,
+ // then ContentIsInTraversalRange can stop null-checking its second arg.
+ bool intersects =
+ ContentIsInTraversalRange(mRange, nextNode, aDir == eForward);
+ if (intersects) {
+ // Now find the nxt/prv node after/before this node
+ nsresult rv = AdvanceNode(parent, aNewNode, aDir);
+ if (NS_SUCCEEDED(rv) && aNewNode) {
+ return NS_OK;
+ }
+ }
+ }
+
+ // if we get here it pretty much means
+ // we went out of the DOM Range
+ mIsOutOfRange = true;
+
+ return NS_ERROR_FAILURE;
+}
+
+// Helper function to see if the next/prev node should be skipped
+void FilteredContentIterator::CheckAdvNode(nsINode* aNode, bool& aDidSkip,
+ eDirectionType aDir) {
+ aDidSkip = false;
+ mIsOutOfRange = false;
+
+ if (aNode && mFilter) {
+ nsCOMPtr<nsINode> currentNode = aNode;
+ while (1) {
+ if (mFilter->Skip(aNode)) {
+ aDidSkip = true;
+ // Get the next/prev node and then
+ // see if we should skip that
+ nsCOMPtr<nsINode> advNode;
+ nsresult rv = AdvanceNode(aNode, *getter_AddRefs(advNode), aDir);
+ if (NS_SUCCEEDED(rv) && advNode) {
+ aNode = advNode;
+ } else {
+ return; // fell out of range
+ }
+ } else {
+ if (aNode != currentNode) {
+ nsCOMPtr<nsIContent> content(do_QueryInterface(aNode));
+ mCurrentIterator->PositionAt(content);
+ }
+ return; // found something
+ }
+ }
+ }
+}
+
+void FilteredContentIterator::Next() {
+ if (mIsOutOfRange || !mCurrentIterator) {
+ NS_ASSERTION(mCurrentIterator, "Missing iterator!");
+
+ return;
+ }
+
+ // If we are switching directions then
+ // we need to switch how we process the nodes
+ if (mDirection != eForward) {
+ nsresult rv = SwitchDirections(true);
+ if (NS_FAILED(rv)) {
+ return;
+ }
+ }
+
+ mCurrentIterator->Next();
+
+ if (mCurrentIterator->IsDone()) {
+ return;
+ }
+
+ // If we can't get the current node then
+ // don't check to see if we can skip it
+ nsINode* currentNode = mCurrentIterator->GetCurrentNode();
+
+ CheckAdvNode(currentNode, mDidSkip, eForward);
+}
+
+void FilteredContentIterator::Prev() {
+ if (mIsOutOfRange || !mCurrentIterator) {
+ NS_ASSERTION(mCurrentIterator, "Missing iterator!");
+
+ return;
+ }
+
+ // If we are switching directions then
+ // we need to switch how we process the nodes
+ if (mDirection != eBackward) {
+ nsresult rv = SwitchDirections(false);
+ if (NS_FAILED(rv)) {
+ return;
+ }
+ }
+
+ mCurrentIterator->Prev();
+
+ if (mCurrentIterator->IsDone()) {
+ return;
+ }
+
+ // If we can't get the current node then
+ // don't check to see if we can skip it
+ nsINode* currentNode = mCurrentIterator->GetCurrentNode();
+
+ CheckAdvNode(currentNode, mDidSkip, eBackward);
+}
+
+nsINode* FilteredContentIterator::GetCurrentNode() {
+ if (mIsOutOfRange || !mCurrentIterator) {
+ return nullptr;
+ }
+
+ return mCurrentIterator->GetCurrentNode();
+}
+
+bool FilteredContentIterator::IsDone() {
+ if (mIsOutOfRange || !mCurrentIterator) {
+ return true;
+ }
+
+ return mCurrentIterator->IsDone();
+}
+
+nsresult FilteredContentIterator::PositionAt(nsINode* aCurNode) {
+ NS_ENSURE_TRUE(mCurrentIterator, NS_ERROR_FAILURE);
+ mIsOutOfRange = false;
+ return mCurrentIterator->PositionAt(aCurNode);
+}
+
+} // namespace mozilla
diff --git a/editor/spellchecker/FilteredContentIterator.h b/editor/spellchecker/FilteredContentIterator.h
new file mode 100644
index 0000000000..a6fee6c150
--- /dev/null
+++ b/editor/spellchecker/FilteredContentIterator.h
@@ -0,0 +1,82 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef FilteredContentIterator_h
+#define FilteredContentIterator_h
+
+#include "nsComposeTxtSrvFilter.h"
+#include "nsCOMPtr.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsISupportsImpl.h"
+#include "nscore.h"
+#include "mozilla/ContentIterator.h"
+#include "mozilla/UniquePtr.h"
+
+class nsAtom;
+class nsINode;
+class nsRange;
+
+namespace mozilla {
+
+namespace dom {
+class AbstractRange;
+}
+
+class FilteredContentIterator final {
+ public:
+ NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(FilteredContentIterator)
+ NS_DECL_CYCLE_COLLECTION_NATIVE_CLASS(FilteredContentIterator)
+
+ explicit FilteredContentIterator(UniquePtr<nsComposeTxtSrvFilter> aFilter);
+
+ nsresult Init(nsINode* aRoot);
+ nsresult Init(const dom::AbstractRange* aAbstractRange);
+ nsresult Init(nsINode* aStartContainer, uint32_t aStartOffset,
+ nsINode* aEndContainer, uint32_t aEndOffset);
+ nsresult Init(const RawRangeBoundary& aStartBoundary,
+ const RawRangeBoundary& aEndBoundary);
+ void First();
+ void Last();
+ void Next();
+ void Prev();
+ nsINode* GetCurrentNode();
+ bool IsDone();
+ nsresult PositionAt(nsINode* aCurNode);
+
+ /* Helpers */
+ bool DidSkip() { return mDidSkip; }
+ void ClearDidSkip() { mDidSkip = false; }
+
+ protected:
+ FilteredContentIterator()
+ : mDidSkip(false), mIsOutOfRange(false), mDirection{eDirNotSet} {}
+
+ virtual ~FilteredContentIterator();
+
+ /**
+ * Callers must guarantee that mRange isn't nullptr and it's positioned.
+ */
+ nsresult InitWithRange();
+
+ // enum to give us the direction
+ typedef enum { eDirNotSet, eForward, eBackward } eDirectionType;
+ nsresult AdvanceNode(nsINode* aNode, nsINode*& aNewNode, eDirectionType aDir);
+ void CheckAdvNode(nsINode* aNode, bool& aDidSkip, eDirectionType aDir);
+ nsresult SwitchDirections(bool aChangeToForward);
+
+ SafeContentIteratorBase* MOZ_NON_OWNING_REF mCurrentIterator;
+ PostContentIterator mPostIterator;
+ PreContentIterator mPreIterator;
+
+ UniquePtr<nsComposeTxtSrvFilter> mFilter;
+ RefPtr<nsRange> mRange;
+ bool mDidSkip;
+ bool mIsOutOfRange;
+ eDirectionType mDirection;
+};
+
+} // namespace mozilla
+
+#endif // #ifndef FilteredContentIterator_h
diff --git a/editor/spellchecker/TextServicesDocument.cpp b/editor/spellchecker/TextServicesDocument.cpp
new file mode 100644
index 0000000000..1417cf79ae
--- /dev/null
+++ b/editor/spellchecker/TextServicesDocument.cpp
@@ -0,0 +1,2787 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "TextServicesDocument.h"
+
+#include "EditorBase.h" // for EditorBase
+#include "EditorUtils.h" // for AutoTransactionBatchExternal
+#include "FilteredContentIterator.h" // for FilteredContentIterator
+#include "HTMLEditHelpers.h" // for BlockInlineCheck
+#include "HTMLEditUtils.h" // for HTMLEditUtils
+
+#include "mozilla/Assertions.h" // for MOZ_ASSERT, etc
+#include "mozilla/IntegerRange.h" // for IntegerRange
+#include "mozilla/mozalloc.h" // for operator new, etc
+#include "mozilla/OwningNonNull.h"
+#include "mozilla/UniquePtr.h" // for UniquePtr
+#include "mozilla/dom/AbstractRange.h" // for AbstractRange
+#include "mozilla/dom/Element.h"
+#include "mozilla/dom/Selection.h"
+#include "mozilla/dom/StaticRange.h" // for StaticRange
+#include "mozilla/dom/Text.h"
+#include "mozilla/intl/WordBreaker.h" // for WordRange, WordBreaker
+
+#include "nsAString.h" // for nsAString::Length, etc
+#include "nsContentUtils.h" // for nsContentUtils
+#include "nsComposeTxtSrvFilter.h"
+#include "nsDebug.h" // for NS_ENSURE_TRUE, etc
+#include "nsDependentSubstring.h" // for Substring
+#include "nsError.h" // for NS_OK, NS_ERROR_FAILURE, etc
+#include "nsGenericHTMLElement.h" // for nsGenericHTMLElement
+#include "nsIContent.h" // for nsIContent, etc
+#include "nsID.h" // for NS_GET_IID
+#include "nsIEditor.h" // for nsIEditor, etc
+#include "nsIEditorSpellCheck.h" // for nsIEditorSpellCheck, etc
+#include "nsINode.h" // for nsINode
+#include "nsISelectionController.h" // for nsISelectionController, etc
+#include "nsISupports.h" // for nsISupports
+#include "nsISupportsUtils.h" // for NS_IF_ADDREF, NS_ADDREF, etc
+#include "nsRange.h" // for nsRange
+#include "nsString.h" // for nsString, nsAutoString
+#include "nscore.h" // for nsresult, NS_IMETHODIMP, etc
+
+namespace mozilla {
+
+using namespace dom;
+
+/**
+ * OffsetEntry manages a range in a text node. It stores 2 offset values,
+ * one is offset in the text node, the other is offset in all text in
+ * the ancestor block of the text node. And the length is managing length
+ * in the text node, starting from the offset in text node.
+ * In other words, a text node may be managed by multiple instances of this
+ * class.
+ */
+class OffsetEntry final {
+ public:
+ OffsetEntry() = delete;
+
+ /**
+ * @param aTextNode The text node which will be manged by the instance.
+ * @param aOffsetInTextInBlock
+ * Start offset in the text node which will be managed by
+ * the instance.
+ * @param aLength Length in the text node which will be managed by the
+ * instance.
+ */
+ OffsetEntry(Text& aTextNode, uint32_t aOffsetInTextInBlock, uint32_t aLength)
+ : mTextNode(aTextNode),
+ mOffsetInTextNode(0),
+ mOffsetInTextInBlock(aOffsetInTextInBlock),
+ mLength(aLength),
+ mIsInsertedText(false),
+ mIsValid(true) {}
+
+ /**
+ * EndOffsetInTextNode() returns end offset in the text node, which is
+ * managed by the instance.
+ */
+ uint32_t EndOffsetInTextNode() const { return mOffsetInTextNode + mLength; }
+
+ /**
+ * OffsetInTextNodeIsInRangeOrEndOffset() checks whether the offset in
+ * the text node is managed by the instance or not.
+ */
+ bool OffsetInTextNodeIsInRangeOrEndOffset(uint32_t aOffsetInTextNode) const {
+ return aOffsetInTextNode >= mOffsetInTextNode &&
+ aOffsetInTextNode <= EndOffsetInTextNode();
+ }
+
+ /**
+ * EndOffsetInTextInBlock() returns end offset in the all text in ancestor
+ * block of the text node, which is managed by the instance.
+ */
+ uint32_t EndOffsetInTextInBlock() const {
+ return mOffsetInTextInBlock + mLength;
+ }
+
+ /**
+ * OffsetInTextNodeIsInRangeOrEndOffset() checks whether the offset in
+ * the all text in ancestor block of the text node is managed by the instance
+ * or not.
+ */
+ bool OffsetInTextInBlockIsInRangeOrEndOffset(
+ uint32_t aOffsetInTextInBlock) const {
+ return aOffsetInTextInBlock >= mOffsetInTextInBlock &&
+ aOffsetInTextInBlock <= EndOffsetInTextInBlock();
+ }
+
+ OwningNonNull<Text> mTextNode;
+ uint32_t mOffsetInTextNode;
+ // Offset in all text in the closest ancestor block of mTextNode.
+ uint32_t mOffsetInTextInBlock;
+ uint32_t mLength;
+ bool mIsInsertedText;
+ bool mIsValid;
+};
+
+template <typename ElementType>
+struct MOZ_STACK_CLASS ArrayLengthMutationGuard final {
+ ArrayLengthMutationGuard() = delete;
+ explicit ArrayLengthMutationGuard(const nsTArray<ElementType>& aArray)
+ : mArray(aArray), mOldLength(aArray.Length()) {}
+ ~ArrayLengthMutationGuard() {
+ if (mArray.Length() != mOldLength) {
+ MOZ_CRASH("The array length was changed unexpectedly");
+ }
+ }
+
+ private:
+ const nsTArray<ElementType>& mArray;
+ size_t mOldLength;
+};
+
+#define LockOffsetEntryArrayLengthInDebugBuild(aName, aArray) \
+ DebugOnly<ArrayLengthMutationGuard<UniquePtr<OffsetEntry>>> const aName = \
+ ArrayLengthMutationGuard<UniquePtr<OffsetEntry>>(aArray);
+
+TextServicesDocument::TextServicesDocument()
+ : mTxtSvcFilterType(0), mIteratorStatus(IteratorStatus::eDone) {}
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(TextServicesDocument)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(TextServicesDocument)
+
+NS_INTERFACE_MAP_BEGIN(TextServicesDocument)
+ NS_INTERFACE_MAP_ENTRY(nsIEditActionListener)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIEditActionListener)
+ NS_INTERFACE_MAP_ENTRIES_CYCLE_COLLECTION(TextServicesDocument)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTION(TextServicesDocument, mDocument, mSelCon, mEditorBase,
+ mFilteredIter, mPrevTextBlock, mNextTextBlock, mExtent)
+
+nsresult TextServicesDocument::InitWithEditor(nsIEditor* aEditor) {
+ nsCOMPtr<nsISelectionController> selCon;
+
+ NS_ENSURE_TRUE(aEditor, NS_ERROR_NULL_POINTER);
+
+ // Check to see if we already have an mSelCon. If we do, it
+ // better be the same one the editor uses!
+
+ nsresult rv = aEditor->GetSelectionController(getter_AddRefs(selCon));
+
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ if (!selCon || (mSelCon && selCon != mSelCon)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ if (!mSelCon) {
+ mSelCon = selCon;
+ }
+
+ // Check to see if we already have an mDocument. If we do, it
+ // better be the same one the editor uses!
+
+ RefPtr<Document> doc = aEditor->AsEditorBase()->GetDocument();
+ if (!doc || (mDocument && doc != mDocument)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ if (!mDocument) {
+ mDocument = doc;
+
+ rv = CreateDocumentContentIterator(getter_AddRefs(mFilteredIter));
+
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ mIteratorStatus = IteratorStatus::eDone;
+
+ rv = FirstBlock();
+
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+ }
+
+ mEditorBase = aEditor->AsEditorBase();
+
+ rv = aEditor->AddEditActionListener(this);
+
+ return rv;
+}
+
+nsresult TextServicesDocument::SetExtent(const AbstractRange* aAbstractRange) {
+ MOZ_ASSERT(aAbstractRange);
+
+ if (NS_WARN_IF(!mDocument)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // We need to store a copy of aAbstractRange since we don't know where it
+ // came from.
+ mExtent = nsRange::Create(aAbstractRange, IgnoreErrors());
+ if (NS_WARN_IF(!mExtent)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // Create a new iterator based on our new extent range.
+ nsresult rv =
+ CreateFilteredContentIterator(mExtent, getter_AddRefs(mFilteredIter));
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ // Now position the iterator at the start of the first block
+ // in the range.
+ mIteratorStatus = IteratorStatus::eDone;
+
+ rv = FirstBlock();
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "FirstBlock() failed");
+ return rv;
+}
+
+nsresult TextServicesDocument::ExpandRangeToWordBoundaries(
+ StaticRange* aStaticRange) {
+ MOZ_ASSERT(aStaticRange);
+
+ // Get the end points of the range.
+
+ nsCOMPtr<nsINode> rngStartNode, rngEndNode;
+ uint32_t rngStartOffset, rngEndOffset;
+
+ nsresult rv = GetRangeEndPoints(aStaticRange, getter_AddRefs(rngStartNode),
+ &rngStartOffset, getter_AddRefs(rngEndNode),
+ &rngEndOffset);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ // Create a content iterator based on the range.
+ RefPtr<FilteredContentIterator> filteredIter;
+ rv =
+ CreateFilteredContentIterator(aStaticRange, getter_AddRefs(filteredIter));
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ // Find the first text node in the range.
+ IteratorStatus iterStatus = IteratorStatus::eDone;
+ rv = FirstTextNode(filteredIter, &iterStatus);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ if (iterStatus == IteratorStatus::eDone) {
+ // No text was found so there's no adjustment necessary!
+ return NS_OK;
+ }
+
+ nsINode* firstText = filteredIter->GetCurrentNode();
+ if (NS_WARN_IF(!firstText)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // Find the last text node in the range.
+
+ rv = LastTextNode(filteredIter, &iterStatus);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ if (iterStatus == IteratorStatus::eDone) {
+ // We should never get here because a first text block
+ // was found above.
+ NS_ASSERTION(false, "Found a first without a last!");
+ return NS_ERROR_FAILURE;
+ }
+
+ nsINode* lastText = filteredIter->GetCurrentNode();
+ if (NS_WARN_IF(!lastText)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // Now make sure our end points are in terms of text nodes in the range!
+
+ if (rngStartNode != firstText) {
+ // The range includes the start of the first text node!
+ rngStartNode = firstText;
+ rngStartOffset = 0;
+ }
+
+ if (rngEndNode != lastText) {
+ // The range includes the end of the last text node!
+ rngEndNode = lastText;
+ rngEndOffset = lastText->Length();
+ }
+
+ // Create a doc iterator so that we can scan beyond
+ // the bounds of the extent range.
+
+ RefPtr<FilteredContentIterator> docFilteredIter;
+ rv = CreateDocumentContentIterator(getter_AddRefs(docFilteredIter));
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ // Grab all the text in the block containing our
+ // first text node.
+ rv = docFilteredIter->PositionAt(firstText);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ iterStatus = IteratorStatus::eValid;
+
+ OffsetEntryArray offsetTable;
+ nsAutoString blockStr;
+ Result<IteratorStatus, nsresult> result = offsetTable.Init(
+ *docFilteredIter, IteratorStatus::eValid, nullptr, &blockStr);
+ if (result.isErr()) {
+ return result.unwrapErr();
+ }
+
+ Result<EditorDOMRangeInTexts, nsresult> maybeWordRange =
+ offsetTable.FindWordRange(
+ blockStr, EditorRawDOMPoint(rngStartNode, rngStartOffset));
+ offsetTable.Clear();
+ if (maybeWordRange.isErr()) {
+ NS_WARNING(
+ "TextServicesDocument::OffsetEntryArray::FindWordRange() failed");
+ return maybeWordRange.unwrapErr();
+ }
+ rngStartNode = maybeWordRange.inspect().StartRef().GetContainerAs<Text>();
+ rngStartOffset = maybeWordRange.inspect().StartRef().Offset();
+
+ // Grab all the text in the block containing our
+ // last text node.
+
+ rv = docFilteredIter->PositionAt(lastText);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ result = offsetTable.Init(*docFilteredIter, IteratorStatus::eValid, nullptr,
+ &blockStr);
+ if (result.isErr()) {
+ return result.unwrapErr();
+ }
+
+ maybeWordRange = offsetTable.FindWordRange(
+ blockStr, EditorRawDOMPoint(rngEndNode, rngEndOffset));
+ offsetTable.Clear();
+ if (maybeWordRange.isErr()) {
+ NS_WARNING(
+ "TextServicesDocument::OffsetEntryArray::FindWordRange() failed");
+ return maybeWordRange.unwrapErr();
+ }
+
+ // To prevent expanding the range too much, we only change
+ // rngEndNode and rngEndOffset if it isn't already at the start of the
+ // word and isn't equivalent to rngStartNode and rngStartOffset.
+
+ if (rngEndNode !=
+ maybeWordRange.inspect().StartRef().GetContainerAs<Text>() ||
+ rngEndOffset != maybeWordRange.inspect().StartRef().Offset() ||
+ (rngEndNode == rngStartNode && rngEndOffset == rngStartOffset)) {
+ rngEndNode = maybeWordRange.inspect().EndRef().GetContainerAs<Text>();
+ rngEndOffset = maybeWordRange.inspect().EndRef().Offset();
+ }
+
+ // Now adjust the range so that it uses our new end points.
+ rv = aStaticRange->SetStartAndEnd(rngStartNode, rngStartOffset, rngEndNode,
+ rngEndOffset);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "Failed to update the given range");
+ return rv;
+}
+
+nsresult TextServicesDocument::SetFilterType(uint32_t aFilterType) {
+ mTxtSvcFilterType = aFilterType;
+
+ return NS_OK;
+}
+
+nsresult TextServicesDocument::GetCurrentTextBlock(nsAString& aStr) {
+ aStr.Truncate();
+
+ NS_ENSURE_TRUE(mFilteredIter, NS_ERROR_FAILURE);
+
+ Result<IteratorStatus, nsresult> result =
+ mOffsetTable.Init(*mFilteredIter, mIteratorStatus, mExtent, &aStr);
+ if (result.isErr()) {
+ NS_WARNING("OffsetEntryArray::Init() failed");
+ return result.unwrapErr();
+ }
+ mIteratorStatus = result.unwrap();
+ return NS_OK;
+}
+
+nsresult TextServicesDocument::FirstBlock() {
+ NS_ENSURE_TRUE(mFilteredIter, NS_ERROR_FAILURE);
+
+ nsresult rv = FirstTextNode(mFilteredIter, &mIteratorStatus);
+
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ // Keep track of prev and next blocks, just in case
+ // the text service blows away the current block.
+
+ if (mIteratorStatus == IteratorStatus::eValid) {
+ mPrevTextBlock = nullptr;
+ rv = GetFirstTextNodeInNextBlock(getter_AddRefs(mNextTextBlock));
+ } else {
+ // There's no text block in the document!
+
+ mPrevTextBlock = nullptr;
+ mNextTextBlock = nullptr;
+ }
+
+ // XXX Result of FirstTextNode() or GetFirstTextNodeInNextBlock().
+ return rv;
+}
+
+nsresult TextServicesDocument::LastSelectedBlock(
+ BlockSelectionStatus* aSelStatus, uint32_t* aSelOffset,
+ uint32_t* aSelLength) {
+ NS_ENSURE_TRUE(aSelStatus && aSelOffset && aSelLength, NS_ERROR_NULL_POINTER);
+
+ mIteratorStatus = IteratorStatus::eDone;
+
+ *aSelStatus = BlockSelectionStatus::eBlockNotFound;
+ *aSelOffset = *aSelLength = UINT32_MAX;
+
+ if (!mSelCon || !mFilteredIter) {
+ return NS_ERROR_FAILURE;
+ }
+
+ RefPtr<Selection> selection =
+ mSelCon->GetSelection(nsISelectionController::SELECTION_NORMAL);
+ if (NS_WARN_IF(!selection)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ RefPtr<const nsRange> range;
+ nsCOMPtr<nsINode> parent;
+
+ if (selection->IsCollapsed()) {
+ // We have a caret. Check if the caret is in a text node.
+ // If it is, make the text node's block the current block.
+ // If the caret isn't in a text node, search forwards in
+ // the document, till we find a text node.
+
+ range = selection->GetRangeAt(0);
+ if (!range) {
+ return NS_ERROR_FAILURE;
+ }
+
+ parent = range->GetStartContainer();
+ if (!parent) {
+ return NS_ERROR_FAILURE;
+ }
+
+ nsresult rv;
+ if (parent->IsText()) {
+ // The caret is in a text node. Find the beginning
+ // of the text block containing this text node and
+ // return.
+
+ rv = mFilteredIter->PositionAt(parent->AsText());
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ rv = FirstTextNodeInCurrentBlock(mFilteredIter);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ Result<IteratorStatus, nsresult> result =
+ mOffsetTable.Init(*mFilteredIter, IteratorStatus::eValid, mExtent);
+ if (result.isErr()) {
+ NS_WARNING("OffsetEntryArray::Init() failed");
+ mIteratorStatus = IteratorStatus::eValid; // XXX
+ return result.unwrapErr();
+ }
+ mIteratorStatus = result.unwrap();
+
+ rv = GetSelection(aSelStatus, aSelOffset, aSelLength);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ if (*aSelStatus == BlockSelectionStatus::eBlockContains) {
+ rv = SetSelectionInternal(*aSelOffset, *aSelLength, false);
+ }
+ } else {
+ // The caret isn't in a text node. Create an iterator
+ // based on a range that extends from the current caret
+ // position to the end of the document, then walk forwards
+ // till you find a text node, then find the beginning of it's block.
+
+ range = CreateDocumentContentRootToNodeOffsetRange(
+ parent, range->StartOffset(), false);
+ if (NS_WARN_IF(!range)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ if (range->Collapsed()) {
+ // If we get here, the range is collapsed because there is nothing after
+ // the caret! Just return NS_OK;
+ return NS_OK;
+ }
+
+ RefPtr<FilteredContentIterator> filteredIter;
+ rv = CreateFilteredContentIterator(range, getter_AddRefs(filteredIter));
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ filteredIter->First();
+
+ Text* textNode = nullptr;
+ for (; !filteredIter->IsDone(); filteredIter->Next()) {
+ nsINode* currentNode = filteredIter->GetCurrentNode();
+ if (currentNode->IsText()) {
+ textNode = currentNode->AsText();
+ break;
+ }
+ }
+
+ if (!textNode) {
+ return NS_OK;
+ }
+
+ rv = mFilteredIter->PositionAt(textNode);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ rv = FirstTextNodeInCurrentBlock(mFilteredIter);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ Result<IteratorStatus, nsresult> result = mOffsetTable.Init(
+ *mFilteredIter, IteratorStatus::eValid, mExtent, nullptr);
+ if (result.isErr()) {
+ NS_WARNING("OffsetEntryArray::Init() failed");
+ mIteratorStatus = IteratorStatus::eValid; // XXX
+ return result.unwrapErr();
+ }
+ mIteratorStatus = result.inspect();
+
+ rv = GetSelection(aSelStatus, aSelOffset, aSelLength);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+ }
+
+ // Result of SetSelectionInternal() in the |if| block or NS_OK.
+ return rv;
+ }
+
+ // If we get here, we have an uncollapsed selection!
+ // Look backwards through each range in the selection till you
+ // find the first text node. If you find one, find the
+ // beginning of its text block, and make it the current
+ // block.
+
+ const uint32_t rangeCount = selection->RangeCount();
+ MOZ_ASSERT(
+ rangeCount,
+ "Selection is not collapsed, so, the range count should be 1 or larger");
+
+ // XXX: We may need to add some code here to make sure
+ // the ranges are sorted in document appearance order!
+
+ for (const uint32_t i : Reversed(IntegerRange(rangeCount))) {
+ MOZ_ASSERT(selection->RangeCount() == rangeCount);
+ range = selection->GetRangeAt(i);
+ if (MOZ_UNLIKELY(!range)) {
+ return NS_OK; // XXX Really?
+ }
+
+ // Create an iterator for the range.
+
+ RefPtr<FilteredContentIterator> filteredIter;
+ nsresult rv =
+ CreateFilteredContentIterator(range, getter_AddRefs(filteredIter));
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ filteredIter->Last();
+
+ // Now walk through the range till we find a text node.
+
+ for (; !filteredIter->IsDone(); filteredIter->Prev()) {
+ if (filteredIter->GetCurrentNode()->NodeType() == nsINode::TEXT_NODE) {
+ // We found a text node, so position the document's
+ // iterator at the beginning of the block, then get
+ // the selection in terms of the string offset.
+
+ nsresult rv = mFilteredIter->PositionAt(filteredIter->GetCurrentNode());
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ rv = FirstTextNodeInCurrentBlock(mFilteredIter);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ mIteratorStatus = IteratorStatus::eValid;
+
+ Result<IteratorStatus, nsresult> result =
+ mOffsetTable.Init(*mFilteredIter, IteratorStatus::eValid, mExtent);
+ if (result.isErr()) {
+ NS_WARNING("OffsetEntryArray::Init() failed");
+ mIteratorStatus = IteratorStatus::eValid; // XXX
+ return result.unwrapErr();
+ }
+ mIteratorStatus = result.unwrap();
+
+ return GetSelection(aSelStatus, aSelOffset, aSelLength);
+ }
+ }
+ }
+
+ // If we get here, we didn't find any text node in the selection!
+ // Create a range that extends from the end of the selection,
+ // to the end of the document, then iterate forwards through
+ // it till you find a text node!
+ range = rangeCount > 0 ? selection->GetRangeAt(rangeCount - 1) : nullptr;
+ if (!range) {
+ return NS_ERROR_FAILURE;
+ }
+
+ parent = range->GetEndContainer();
+ if (!parent) {
+ return NS_ERROR_FAILURE;
+ }
+
+ range = CreateDocumentContentRootToNodeOffsetRange(parent, range->EndOffset(),
+ false);
+ if (NS_WARN_IF(!range)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ if (range->Collapsed()) {
+ // If we get here, the range is collapsed because there is nothing after
+ // the current selection! Just return NS_OK;
+ return NS_OK;
+ }
+
+ RefPtr<FilteredContentIterator> filteredIter;
+ nsresult rv =
+ CreateFilteredContentIterator(range, getter_AddRefs(filteredIter));
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ filteredIter->First();
+
+ for (; !filteredIter->IsDone(); filteredIter->Next()) {
+ if (filteredIter->GetCurrentNode()->NodeType() == nsINode::TEXT_NODE) {
+ // We found a text node! Adjust the document's iterator to point
+ // to the beginning of its text block, then get the current selection.
+ nsresult rv = mFilteredIter->PositionAt(filteredIter->GetCurrentNode());
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ rv = FirstTextNodeInCurrentBlock(mFilteredIter);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ Result<IteratorStatus, nsresult> result =
+ mOffsetTable.Init(*mFilteredIter, IteratorStatus::eValid, mExtent);
+ if (result.isErr()) {
+ NS_WARNING("OffsetEntryArray::Init() failed");
+ mIteratorStatus = IteratorStatus::eValid; // XXX
+ return result.unwrapErr();
+ }
+ mIteratorStatus = result.unwrap();
+
+ rv = GetSelection(aSelStatus, aSelOffset, aSelLength);
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "TextServicesDocument::GetSelection() failed");
+ return rv;
+ }
+ }
+
+ // If we get here, we didn't find any block before or inside
+ // the selection! Just return OK.
+ return NS_OK;
+}
+
+nsresult TextServicesDocument::PrevBlock() {
+ NS_ENSURE_TRUE(mFilteredIter, NS_ERROR_FAILURE);
+
+ if (mIteratorStatus == IteratorStatus::eDone) {
+ return NS_OK;
+ }
+
+ switch (mIteratorStatus) {
+ case IteratorStatus::eValid:
+ case IteratorStatus::eNext: {
+ nsresult rv = FirstTextNodeInPrevBlock(mFilteredIter);
+
+ if (NS_FAILED(rv)) {
+ mIteratorStatus = IteratorStatus::eDone;
+ return rv;
+ }
+
+ if (mFilteredIter->IsDone()) {
+ mIteratorStatus = IteratorStatus::eDone;
+ return NS_OK;
+ }
+
+ mIteratorStatus = IteratorStatus::eValid;
+ break;
+ }
+ case IteratorStatus::ePrev:
+
+ // The iterator already points to the previous
+ // block, so don't do anything.
+
+ mIteratorStatus = IteratorStatus::eValid;
+ break;
+
+ default:
+
+ mIteratorStatus = IteratorStatus::eDone;
+ break;
+ }
+
+ // Keep track of prev and next blocks, just in case
+ // the text service blows away the current block.
+ nsresult rv = NS_OK;
+ if (mIteratorStatus == IteratorStatus::eValid) {
+ GetFirstTextNodeInPrevBlock(getter_AddRefs(mPrevTextBlock));
+ rv = GetFirstTextNodeInNextBlock(getter_AddRefs(mNextTextBlock));
+ } else {
+ // We must be done!
+ mPrevTextBlock = nullptr;
+ mNextTextBlock = nullptr;
+ }
+
+ // XXX The result of GetFirstTextNodeInNextBlock() or NS_OK.
+ return rv;
+}
+
+nsresult TextServicesDocument::NextBlock() {
+ NS_ENSURE_TRUE(mFilteredIter, NS_ERROR_FAILURE);
+
+ if (mIteratorStatus == IteratorStatus::eDone) {
+ return NS_OK;
+ }
+
+ switch (mIteratorStatus) {
+ case IteratorStatus::eValid: {
+ // Advance the iterator to the next text block.
+
+ nsresult rv = FirstTextNodeInNextBlock(mFilteredIter);
+
+ if (NS_FAILED(rv)) {
+ mIteratorStatus = IteratorStatus::eDone;
+ return rv;
+ }
+
+ if (mFilteredIter->IsDone()) {
+ mIteratorStatus = IteratorStatus::eDone;
+ return NS_OK;
+ }
+
+ mIteratorStatus = IteratorStatus::eValid;
+ break;
+ }
+ case IteratorStatus::eNext:
+
+ // The iterator already points to the next block,
+ // so don't do anything to it!
+
+ mIteratorStatus = IteratorStatus::eValid;
+ break;
+
+ case IteratorStatus::ePrev:
+
+ // If the iterator is pointing to the previous block,
+ // we know that there is no next text block! Just
+ // fall through to the default case!
+
+ default:
+
+ mIteratorStatus = IteratorStatus::eDone;
+ break;
+ }
+
+ // Keep track of prev and next blocks, just in case
+ // the text service blows away the current block.
+ nsresult rv = NS_OK;
+ if (mIteratorStatus == IteratorStatus::eValid) {
+ GetFirstTextNodeInPrevBlock(getter_AddRefs(mPrevTextBlock));
+ rv = GetFirstTextNodeInNextBlock(getter_AddRefs(mNextTextBlock));
+ } else {
+ // We must be done.
+ mPrevTextBlock = nullptr;
+ mNextTextBlock = nullptr;
+ }
+
+ // The result of GetFirstTextNodeInNextBlock() or NS_OK.
+ return rv;
+}
+
+nsresult TextServicesDocument::IsDone(bool* aIsDone) {
+ NS_ENSURE_TRUE(aIsDone, NS_ERROR_NULL_POINTER);
+
+ *aIsDone = false;
+
+ NS_ENSURE_TRUE(mFilteredIter, NS_ERROR_FAILURE);
+
+ *aIsDone = mIteratorStatus == IteratorStatus::eDone;
+
+ return NS_OK;
+}
+
+nsresult TextServicesDocument::SetSelection(uint32_t aOffset,
+ uint32_t aLength) {
+ NS_ENSURE_TRUE(mSelCon, NS_ERROR_FAILURE);
+
+ return SetSelectionInternal(aOffset, aLength, true);
+}
+
+nsresult TextServicesDocument::ScrollSelectionIntoView() {
+ NS_ENSURE_TRUE(mSelCon, NS_ERROR_FAILURE);
+
+ // After ScrollSelectionIntoView(), the pending notifications might be flushed
+ // and PresShell/PresContext/Frames may be dead. See bug 418470.
+ nsresult rv = mSelCon->ScrollSelectionIntoView(
+ nsISelectionController::SELECTION_NORMAL,
+ nsISelectionController::SELECTION_FOCUS_REGION,
+ nsISelectionController::SCROLL_SYNCHRONOUS);
+
+ return rv;
+}
+
+nsresult TextServicesDocument::OffsetEntryArray::WillDeleteSelection() {
+ MOZ_ASSERT(mSelection.IsSet());
+ MOZ_ASSERT(!mSelection.IsCollapsed());
+
+ for (size_t i = mSelection.StartIndex(); i <= mSelection.EndIndex(); i++) {
+ OffsetEntry* entry = ElementAt(i).get();
+ if (i == mSelection.StartIndex()) {
+ // Calculate the length of the selection. Note that the
+ // selection length can be zero if the start of the selection
+ // is at the very end of a text node entry.
+ uint32_t selLength;
+ if (entry->mIsInsertedText) {
+ // Inserted text offset entries have no width when
+ // talking in terms of string offsets! If the beginning
+ // of the selection is in an inserted text offset entry,
+ // the caret is always at the end of the entry!
+ selLength = 0;
+ } else {
+ selLength = entry->EndOffsetInTextInBlock() -
+ mSelection.StartOffsetInTextInBlock();
+ }
+
+ if (selLength > 0) {
+ if (mSelection.StartOffsetInTextInBlock() >
+ entry->mOffsetInTextInBlock) {
+ // Selection doesn't start at the beginning of the
+ // text node entry. We need to split this entry into
+ // two pieces, the piece before the selection, and
+ // the piece inside the selection.
+ nsresult rv = SplitElementAt(i, selLength);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("selLength was invalid for the OffsetEntry");
+ return rv;
+ }
+
+ // Adjust selection indexes to account for new entry:
+ MOZ_DIAGNOSTIC_ASSERT(mSelection.StartIndex() + 1 < Length());
+ MOZ_DIAGNOSTIC_ASSERT(mSelection.EndIndex() + 1 < Length());
+ mSelection.SetIndexes(mSelection.StartIndex() + 1,
+ mSelection.EndIndex() + 1);
+ entry = ElementAt(++i).get();
+ }
+
+ if (mSelection.StartIndex() < mSelection.EndIndex()) {
+ // The entire entry is contained in the selection. Mark the
+ // entry invalid.
+ entry->mIsValid = false;
+ }
+ }
+ }
+
+ if (i == mSelection.EndIndex()) {
+ if (entry->mIsInsertedText) {
+ // Inserted text offset entries have no width when
+ // talking in terms of string offsets! If the end
+ // of the selection is in an inserted text offset entry,
+ // the selection includes the entire entry!
+ entry->mIsValid = false;
+ } else {
+ // Calculate the length of the selection. Note that the
+ // selection length can be zero if the end of the selection
+ // is at the very beginning of a text node entry.
+
+ const uint32_t selLength =
+ mSelection.EndOffsetInTextInBlock() - entry->mOffsetInTextInBlock;
+ if (selLength) {
+ if (mSelection.EndOffsetInTextInBlock() <
+ entry->EndOffsetInTextInBlock()) {
+ // mOffsetInTextInBlock is guaranteed to be inside the selection,
+ // even when mSelection.IsInSameElement() is true.
+ nsresult rv = SplitElementAt(i, entry->mLength - selLength);
+ if (NS_FAILED(rv)) {
+ NS_WARNING(
+ "entry->mLength - selLength was invalid for the OffsetEntry");
+ return rv;
+ }
+
+ // Update the entry fields:
+ ElementAt(i + 1)->mOffsetInTextNode = entry->mOffsetInTextNode;
+ }
+
+ if (mSelection.EndOffsetInTextInBlock() ==
+ entry->EndOffsetInTextInBlock()) {
+ // The entire entry is contained in the selection. Mark the
+ // entry invalid.
+ entry->mIsValid = false;
+ }
+ }
+ }
+ }
+
+ if (i != mSelection.StartIndex() && i != mSelection.EndIndex()) {
+ // The entire entry is contained in the selection. Mark the
+ // entry invalid.
+ entry->mIsValid = false;
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult TextServicesDocument::DeleteSelection() {
+ if (NS_WARN_IF(!mEditorBase) ||
+ NS_WARN_IF(!mOffsetTable.mSelection.IsSet())) {
+ return NS_ERROR_FAILURE;
+ }
+
+ if (mOffsetTable.mSelection.IsCollapsed()) {
+ return NS_OK;
+ }
+
+ // If we have an mExtent, save off its current set of
+ // end points so we can compare them against mExtent's
+ // set after the deletion of the content.
+
+ nsCOMPtr<nsINode> origStartNode, origEndNode;
+ uint32_t origStartOffset = 0, origEndOffset = 0;
+
+ if (mExtent) {
+ nsresult rv = GetRangeEndPoints(
+ mExtent, getter_AddRefs(origStartNode), &origStartOffset,
+ getter_AddRefs(origEndNode), &origEndOffset);
+
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+ }
+
+ if (NS_FAILED(mOffsetTable.WillDeleteSelection())) {
+ NS_WARNING(
+ "TextServicesDocument::OffsetEntryTable::WillDeleteSelection() failed");
+ return NS_ERROR_FAILURE;
+ }
+
+ // Make sure mFilteredIter always points to something valid!
+ AdjustContentIterator();
+
+ // Now delete the actual content!
+ OwningNonNull<EditorBase> editorBase = *mEditorBase;
+ nsresult rv = editorBase->DeleteSelectionAsAction(nsIEditor::ePrevious,
+ nsIEditor::eStrip);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ // Now that we've actually deleted the selected content,
+ // check to see if our mExtent has changed, if so, then
+ // we have to create a new content iterator!
+
+ if (origStartNode && origEndNode) {
+ nsCOMPtr<nsINode> curStartNode, curEndNode;
+ uint32_t curStartOffset = 0, curEndOffset = 0;
+
+ rv = GetRangeEndPoints(mExtent, getter_AddRefs(curStartNode),
+ &curStartOffset, getter_AddRefs(curEndNode),
+ &curEndOffset);
+
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ if (origStartNode != curStartNode || origEndNode != curEndNode) {
+ // The range has changed, so we need to create a new content
+ // iterator based on the new range.
+ nsCOMPtr<nsIContent> curContent;
+ if (mIteratorStatus != IteratorStatus::eDone) {
+ // The old iterator is still pointing to something valid,
+ // so get its current node so we can restore it after we
+ // create the new iterator!
+ curContent = mFilteredIter->GetCurrentNode()
+ ? mFilteredIter->GetCurrentNode()->AsContent()
+ : nullptr;
+ }
+
+ // Create the new iterator.
+ rv =
+ CreateFilteredContentIterator(mExtent, getter_AddRefs(mFilteredIter));
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ // Now make the new iterator point to the content node
+ // the old one was pointing at.
+ if (curContent) {
+ rv = mFilteredIter->PositionAt(curContent);
+ if (NS_FAILED(rv)) {
+ mIteratorStatus = IteratorStatus::eDone;
+ } else {
+ mIteratorStatus = IteratorStatus::eValid;
+ }
+ }
+ }
+ }
+
+ OffsetEntry* entry = mOffsetTable.DidDeleteSelection();
+ if (entry) {
+ SetSelection(mOffsetTable.mSelection.StartOffsetInTextInBlock(), 0);
+ }
+
+ // Now remove any invalid entries from the offset table.
+ mOffsetTable.RemoveInvalidElements();
+ return NS_OK;
+}
+
+OffsetEntry* TextServicesDocument::OffsetEntryArray::DidDeleteSelection() {
+ MOZ_ASSERT(mSelection.IsSet());
+
+ // Move the caret to the end of the first valid entry.
+ // Start with SelectionStartIndex() since it may still be valid.
+ OffsetEntry* entry = nullptr;
+ for (size_t i = mSelection.StartIndex() + 1; !entry && i > 0; i--) {
+ entry = ElementAt(i - 1).get();
+ if (!entry->mIsValid) {
+ entry = nullptr;
+ } else {
+ MOZ_DIAGNOSTIC_ASSERT(i - 1 < Length());
+ mSelection.Set(i - 1, entry->EndOffsetInTextInBlock());
+ }
+ }
+
+ // If we still don't have a valid entry, move the caret
+ // to the next valid entry after the selection:
+ for (size_t i = mSelection.EndIndex(); !entry && i < Length(); i++) {
+ entry = ElementAt(i).get();
+ if (!entry->mIsValid) {
+ entry = nullptr;
+ } else {
+ MOZ_DIAGNOSTIC_ASSERT(i < Length());
+ mSelection.Set(i, entry->mOffsetInTextInBlock);
+ }
+ }
+
+ if (!entry) {
+ // Uuughh we have no valid offset entry to place our
+ // caret ... just mark the selection invalid.
+ mSelection.Reset();
+ }
+
+ return entry;
+}
+
+nsresult TextServicesDocument::InsertText(const nsAString& aText) {
+ if (NS_WARN_IF(!mEditorBase) ||
+ NS_WARN_IF(!mOffsetTable.mSelection.IsSet())) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // If the selection is not collapsed, we need to save
+ // off the selection offsets so we can restore the
+ // selection and delete the selected content after we've
+ // inserted the new text. This is necessary to try and
+ // retain as much of the original style of the content
+ // being deleted.
+
+ const bool wasSelectionCollapsed = mOffsetTable.mSelection.IsCollapsed();
+ const uint32_t savedSelOffset =
+ mOffsetTable.mSelection.StartOffsetInTextInBlock();
+ const uint32_t savedSelLength = mOffsetTable.mSelection.LengthInTextInBlock();
+
+ if (!wasSelectionCollapsed) {
+ // Collapse to the start of the current selection
+ // for the insert!
+ nsresult rv =
+ SetSelection(mOffsetTable.mSelection.StartOffsetInTextInBlock(), 0);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // AutoTransactionBatchExternal grabs mEditorBase, so, we don't need to grab
+ // the instance with local variable here.
+ OwningNonNull<EditorBase> editorBase = *mEditorBase;
+ AutoTransactionBatchExternal treatAsOneTransaction(editorBase);
+
+ nsresult rv = editorBase->InsertTextAsAction(aText);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("InsertTextAsAction() failed");
+ return rv;
+ }
+
+ RefPtr<Selection> selection =
+ mSelCon->GetSelection(nsISelectionController::SELECTION_NORMAL);
+ rv = mOffsetTable.DidInsertText(selection, aText);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("TextServicesDocument::OffsetEntry::DidInsertText() failed");
+ return rv;
+ }
+
+ if (!wasSelectionCollapsed) {
+ nsresult rv = SetSelection(savedSelOffset, savedSelLength);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ rv = DeleteSelection();
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult TextServicesDocument::OffsetEntryArray::DidInsertText(
+ dom::Selection* aSelection, const nsAString& aInsertedString) {
+ MOZ_ASSERT(mSelection.IsSet());
+
+ // When you touch this method, please make sure that the entry instance
+ // won't be deleted. If you know it'll be deleted, you should set it to
+ // `nullptr`.
+ OffsetEntry* entry = ElementAt(mSelection.StartIndex()).get();
+ OwningNonNull<Text> const textNodeAtStartEntry = entry->mTextNode;
+
+ NS_ASSERTION((entry->mIsValid), "Invalid insertion point!");
+
+ if (entry->mOffsetInTextInBlock == mSelection.StartOffsetInTextInBlock()) {
+ if (entry->mIsInsertedText) {
+ // If the caret is in an inserted text offset entry,
+ // we simply insert the text at the end of the entry.
+ entry->mLength += aInsertedString.Length();
+ } else {
+ // Insert an inserted text offset entry before the current
+ // entry!
+ UniquePtr<OffsetEntry> newInsertedTextEntry =
+ MakeUnique<OffsetEntry>(entry->mTextNode, entry->mOffsetInTextInBlock,
+ aInsertedString.Length());
+ newInsertedTextEntry->mIsInsertedText = true;
+ newInsertedTextEntry->mOffsetInTextNode = entry->mOffsetInTextNode;
+ // XXX(Bug 1631371) Check if this should use a fallible operation as it
+ // pretended earlier.
+ InsertElementAt(mSelection.StartIndex(), std::move(newInsertedTextEntry));
+ }
+ } else if (entry->EndOffsetInTextInBlock() ==
+ mSelection.EndOffsetInTextInBlock()) {
+ // We are inserting text at the end of the current offset entry.
+ // Look at the next valid entry in the table. If it's an inserted
+ // text entry, add to its length and adjust its node offset. If
+ // it isn't, add a new inserted text entry.
+ uint32_t nextIndex = mSelection.StartIndex() + 1;
+ OffsetEntry* insertedTextEntry = nullptr;
+ if (Length() > nextIndex) {
+ insertedTextEntry = ElementAt(nextIndex).get();
+ if (!insertedTextEntry) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // Check if the entry is a match. If it isn't, set
+ // iEntry to zero.
+ if (!insertedTextEntry->mIsInsertedText ||
+ insertedTextEntry->mOffsetInTextInBlock !=
+ mSelection.StartOffsetInTextInBlock()) {
+ insertedTextEntry = nullptr;
+ }
+ }
+
+ if (!insertedTextEntry) {
+ // We didn't find an inserted text offset entry, so
+ // create one.
+ UniquePtr<OffsetEntry> newInsertedTextEntry = MakeUnique<OffsetEntry>(
+ entry->mTextNode, mSelection.StartOffsetInTextInBlock(), 0);
+ newInsertedTextEntry->mOffsetInTextNode = entry->EndOffsetInTextNode();
+ newInsertedTextEntry->mIsInsertedText = true;
+ // XXX(Bug 1631371) Check if this should use a fallible operation as it
+ // pretended earlier.
+ insertedTextEntry =
+ InsertElementAt(nextIndex, std::move(newInsertedTextEntry))->get();
+ }
+
+ // We have a valid inserted text offset entry. Update its
+ // length, adjust the selection indexes, and make sure the
+ // caret is properly placed!
+
+ insertedTextEntry->mLength += aInsertedString.Length();
+
+ MOZ_DIAGNOSTIC_ASSERT(nextIndex < Length());
+ mSelection.SetIndex(nextIndex);
+
+ if (!aSelection) {
+ return NS_OK;
+ }
+
+ OwningNonNull<Text> textNode = insertedTextEntry->mTextNode;
+ nsresult rv = aSelection->CollapseInLimiter(
+ textNode, insertedTextEntry->EndOffsetInTextNode());
+ if (NS_FAILED(rv)) {
+ NS_WARNING("Selection::CollapseInLimiter() failed");
+ return rv;
+ }
+ } else if (entry->EndOffsetInTextInBlock() >
+ mSelection.StartOffsetInTextInBlock()) {
+ // We are inserting text into the middle of the current offset entry.
+ // split the current entry into two parts, then insert an inserted text
+ // entry between them!
+ nsresult rv = SplitElementAt(mSelection.StartIndex(),
+ entry->EndOffsetInTextInBlock() -
+ mSelection.StartOffsetInTextInBlock());
+ if (NS_FAILED(rv)) {
+ NS_WARNING(
+ "entry->EndOffsetInTextInBlock() - "
+ "mSelection.StartOffsetInTextInBlock() was invalid for the "
+ "OffsetEntry");
+ return rv;
+ }
+
+ // XXX(Bug 1631371) Check if this should use a fallible operation as it
+ // pretended earlier.
+ UniquePtr<OffsetEntry>& insertedTextEntry = *InsertElementAt(
+ mSelection.StartIndex() + 1,
+ MakeUnique<OffsetEntry>(entry->mTextNode,
+ mSelection.StartOffsetInTextInBlock(),
+ aInsertedString.Length()));
+ LockOffsetEntryArrayLengthInDebugBuild(observer, *this);
+ insertedTextEntry->mIsInsertedText = true;
+ insertedTextEntry->mOffsetInTextNode = entry->EndOffsetInTextNode();
+ MOZ_DIAGNOSTIC_ASSERT(mSelection.StartIndex() + 1 < Length());
+ mSelection.SetIndex(mSelection.StartIndex() + 1);
+ }
+
+ // We've just finished inserting an inserted text offset entry.
+ // update all entries with the same mTextNode pointer that follow
+ // it in the table!
+
+ for (size_t i = mSelection.StartIndex() + 1; i < Length(); i++) {
+ const UniquePtr<OffsetEntry>& entry = ElementAt(i);
+ LockOffsetEntryArrayLengthInDebugBuild(observer, *this);
+ if (entry->mTextNode != textNodeAtStartEntry) {
+ break;
+ }
+ if (entry->mIsValid) {
+ entry->mOffsetInTextNode += aInsertedString.Length();
+ }
+ }
+
+ return NS_OK;
+}
+
+void TextServicesDocument::DidDeleteContent(const nsIContent& aChildContent) {
+ if (NS_WARN_IF(!mFilteredIter) || !aChildContent.IsText()) {
+ return;
+ }
+
+ Maybe<size_t> maybeNodeIndex =
+ mOffsetTable.FirstIndexOf(*aChildContent.AsText());
+ if (maybeNodeIndex.isNothing()) {
+ // It's okay if the node isn't in the offset table, the
+ // editor could be cleaning house.
+ return;
+ }
+
+ nsINode* node = mFilteredIter->GetCurrentNode();
+ if (node && node == &aChildContent &&
+ mIteratorStatus != IteratorStatus::eDone) {
+ // XXX: This should never really happen because
+ // AdjustContentIterator() should have been called prior
+ // to the delete to try and position the iterator on the
+ // next valid text node in the offset table, and if there
+ // wasn't a next, it would've set mIteratorStatus to eIsDone.
+
+ NS_ERROR("DeleteNode called for current iterator node.");
+ }
+
+ for (size_t nodeIndex = *maybeNodeIndex; nodeIndex < mOffsetTable.Length();
+ nodeIndex++) {
+ const UniquePtr<OffsetEntry>& entry = mOffsetTable[nodeIndex];
+ LockOffsetEntryArrayLengthInDebugBuild(observer, mOffsetTable);
+ if (!entry) {
+ return;
+ }
+
+ if (entry->mTextNode == &aChildContent) {
+ entry->mIsValid = false;
+ }
+ }
+}
+
+void TextServicesDocument::DidJoinContents(
+ const EditorRawDOMPoint& aJoinedPoint, const nsIContent& aRemovedContent) {
+ // Make sure that both nodes are text nodes -- otherwise we don't care.
+ if (!aJoinedPoint.IsInTextNode() || !aRemovedContent.IsText()) {
+ return;
+ }
+
+ // Note: The editor merges the contents of the left node into the
+ // contents of the right.
+
+ Maybe<size_t> maybeRemovedIndex =
+ mOffsetTable.FirstIndexOf(*aRemovedContent.AsText());
+ if (maybeRemovedIndex.isNothing()) {
+ // It's okay if the node isn't in the offset table, the
+ // editor could be cleaning house.
+ return;
+ }
+
+ Maybe<size_t> maybeJoinedIndex =
+ mOffsetTable.FirstIndexOf(*aJoinedPoint.ContainerAs<Text>());
+ if (maybeJoinedIndex.isNothing()) {
+ // It's okay if the node isn't in the offset table, the
+ // editor could be cleaning house.
+ return;
+ }
+
+ const size_t removedIndex = *maybeRemovedIndex;
+ const size_t joinedIndex = *maybeJoinedIndex;
+
+ if (MOZ_UNLIKELY(joinedIndex > removedIndex)) {
+ NS_ASSERTION(joinedIndex < removedIndex, "Indexes out of order.");
+ return;
+ }
+ NS_ASSERTION(mOffsetTable[removedIndex]->mOffsetInTextNode == 0,
+ "Unexpected offset value for rightIndex.");
+
+ // Run through the table and change all entries referring to
+ // the removed node so that they now refer to the joined node,
+ // and adjust offsets if necessary.
+ const uint32_t movedTextDataLength =
+ aJoinedPoint.ContainerAs<Text>()->TextDataLength() -
+ aJoinedPoint.Offset();
+ for (uint32_t i = removedIndex; i < mOffsetTable.Length(); i++) {
+ const UniquePtr<OffsetEntry>& entry = mOffsetTable[i];
+ LockOffsetEntryArrayLengthInDebugBuild(observer, mOffsetTable);
+ if (entry->mTextNode != aRemovedContent.AsText()) {
+ break;
+ }
+ if (entry->mIsValid) {
+ entry->mTextNode = aJoinedPoint.ContainerAs<Text>();
+ // The text was moved from aRemovedContent to end of the container of
+ // aJoinedPoint.
+ entry->mOffsetInTextNode += movedTextDataLength;
+ }
+ }
+
+ // Now check to see if the iterator is pointing to the
+ // left node. If it is, make it point to the joined node!
+ if (mFilteredIter->GetCurrentNode() == aRemovedContent.AsText()) {
+ mFilteredIter->PositionAt(aJoinedPoint.ContainerAs<Text>());
+ }
+}
+
+nsresult TextServicesDocument::CreateFilteredContentIterator(
+ const AbstractRange* aAbstractRange,
+ FilteredContentIterator** aFilteredIter) {
+ if (NS_WARN_IF(!aAbstractRange) || NS_WARN_IF(!aFilteredIter)) {
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ *aFilteredIter = nullptr;
+
+ UniquePtr<nsComposeTxtSrvFilter> composeFilter;
+ switch (mTxtSvcFilterType) {
+ case nsIEditorSpellCheck::FILTERTYPE_NORMAL:
+ composeFilter = nsComposeTxtSrvFilter::CreateNormalFilter();
+ break;
+ case nsIEditorSpellCheck::FILTERTYPE_MAIL:
+ composeFilter = nsComposeTxtSrvFilter::CreateMailFilter();
+ break;
+ }
+
+ // Create a FilteredContentIterator
+ // This class wraps the ContentIterator in order to give itself a chance
+ // to filter out certain content nodes
+ RefPtr<FilteredContentIterator> filter =
+ new FilteredContentIterator(std::move(composeFilter));
+ nsresult rv = filter->Init(aAbstractRange);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+
+ filter.forget(aFilteredIter);
+ return NS_OK;
+}
+
+Element* TextServicesDocument::GetDocumentContentRootNode() const {
+ if (NS_WARN_IF(!mDocument)) {
+ return nullptr;
+ }
+
+ if (mDocument->IsHTMLOrXHTML()) {
+ Element* rootElement = mDocument->GetRootElement();
+ if (rootElement && rootElement->IsXULElement()) {
+ // HTML documents with root XUL elements should eventually be transitioned
+ // to a regular document structure, but for now the content root node will
+ // be the document element.
+ return mDocument->GetDocumentElement();
+ }
+ // For HTML documents, the content root node is the body.
+ return mDocument->GetBody();
+ }
+
+ // For non-HTML documents, the content root node will be the document element.
+ return mDocument->GetDocumentElement();
+}
+
+already_AddRefed<nsRange> TextServicesDocument::CreateDocumentContentRange() {
+ nsCOMPtr<nsINode> node = GetDocumentContentRootNode();
+ if (NS_WARN_IF(!node)) {
+ return nullptr;
+ }
+
+ RefPtr<nsRange> range = nsRange::Create(node);
+ IgnoredErrorResult ignoredError;
+ range->SelectNodeContents(*node, ignoredError);
+ NS_WARNING_ASSERTION(!ignoredError.Failed(), "SelectNodeContents() failed");
+ return range.forget();
+}
+
+already_AddRefed<nsRange>
+TextServicesDocument::CreateDocumentContentRootToNodeOffsetRange(
+ nsINode* aParent, uint32_t aOffset, bool aToStart) {
+ if (NS_WARN_IF(!aParent)) {
+ return nullptr;
+ }
+
+ nsCOMPtr<nsINode> bodyNode = GetDocumentContentRootNode();
+ if (NS_WARN_IF(!bodyNode)) {
+ return nullptr;
+ }
+
+ nsCOMPtr<nsINode> startNode;
+ nsCOMPtr<nsINode> endNode;
+ uint32_t startOffset, endOffset;
+
+ if (aToStart) {
+ // The range should begin at the start of the document
+ // and extend up until (aParent, aOffset).
+ startNode = bodyNode;
+ startOffset = 0;
+ endNode = aParent;
+ endOffset = aOffset;
+ } else {
+ // The range should begin at (aParent, aOffset) and
+ // extend to the end of the document.
+ startNode = aParent;
+ startOffset = aOffset;
+ endNode = bodyNode;
+ endOffset = endNode ? endNode->GetChildCount() : 0;
+ }
+
+ RefPtr<nsRange> range = nsRange::Create(startNode, startOffset, endNode,
+ endOffset, IgnoreErrors());
+ NS_WARNING_ASSERTION(range,
+ "nsRange::Create() failed to create new valid range");
+ return range.forget();
+}
+
+nsresult TextServicesDocument::CreateDocumentContentIterator(
+ FilteredContentIterator** aFilteredIter) {
+ NS_ENSURE_TRUE(aFilteredIter, NS_ERROR_NULL_POINTER);
+
+ RefPtr<nsRange> range = CreateDocumentContentRange();
+ if (NS_WARN_IF(!range)) {
+ *aFilteredIter = nullptr;
+ return NS_ERROR_FAILURE;
+ }
+
+ return CreateFilteredContentIterator(range, aFilteredIter);
+}
+
+nsresult TextServicesDocument::AdjustContentIterator() {
+ NS_ENSURE_TRUE(mFilteredIter, NS_ERROR_FAILURE);
+
+ nsCOMPtr<nsINode> node = mFilteredIter->GetCurrentNode();
+ NS_ENSURE_TRUE(node, NS_ERROR_FAILURE);
+
+ Text* prevValidTextNode = nullptr;
+ Text* nextValidTextNode = nullptr;
+ bool foundEntry = false;
+
+ const size_t tableLength = mOffsetTable.Length();
+ for (size_t i = 0; i < tableLength && !nextValidTextNode; i++) {
+ UniquePtr<OffsetEntry>& entry = mOffsetTable[i];
+ LockOffsetEntryArrayLengthInDebugBuild(observer, mOffsetTable);
+ if (entry->mTextNode == node) {
+ if (entry->mIsValid) {
+ // The iterator is still pointing to something valid!
+ // Do nothing!
+ return NS_OK;
+ }
+ // We found an invalid entry that points to
+ // the current iterator node. Stop looking for
+ // a previous valid node!
+ foundEntry = true;
+ }
+
+ if (entry->mIsValid) {
+ if (!foundEntry) {
+ prevValidTextNode = entry->mTextNode;
+ } else {
+ nextValidTextNode = entry->mTextNode;
+ }
+ }
+ }
+
+ Text* validTextNode = nullptr;
+ if (prevValidTextNode) {
+ validTextNode = prevValidTextNode;
+ } else if (nextValidTextNode) {
+ validTextNode = nextValidTextNode;
+ }
+
+ if (validTextNode) {
+ nsresult rv = mFilteredIter->PositionAt(validTextNode);
+ if (NS_FAILED(rv)) {
+ mIteratorStatus = IteratorStatus::eDone;
+ } else {
+ mIteratorStatus = IteratorStatus::eValid;
+ }
+ return rv;
+ }
+
+ // If we get here, there aren't any valid entries
+ // in the offset table! Try to position the iterator
+ // on the next text block first, then previous if
+ // one doesn't exist!
+
+ if (mNextTextBlock) {
+ nsresult rv = mFilteredIter->PositionAt(mNextTextBlock);
+ if (NS_FAILED(rv)) {
+ mIteratorStatus = IteratorStatus::eDone;
+ return rv;
+ }
+
+ mIteratorStatus = IteratorStatus::eNext;
+ } else if (mPrevTextBlock) {
+ nsresult rv = mFilteredIter->PositionAt(mPrevTextBlock);
+ if (NS_FAILED(rv)) {
+ mIteratorStatus = IteratorStatus::eDone;
+ return rv;
+ }
+
+ mIteratorStatus = IteratorStatus::ePrev;
+ } else {
+ mIteratorStatus = IteratorStatus::eDone;
+ }
+ return NS_OK;
+}
+
+// static
+bool TextServicesDocument::DidSkip(FilteredContentIterator* aFilteredIter) {
+ return aFilteredIter && aFilteredIter->DidSkip();
+}
+
+// static
+void TextServicesDocument::ClearDidSkip(
+ FilteredContentIterator* aFilteredIter) {
+ // Clear filter's skip flag
+ if (aFilteredIter) {
+ aFilteredIter->ClearDidSkip();
+ }
+}
+
+// static
+bool TextServicesDocument::HasSameBlockNodeParent(Text& aTextNode1,
+ Text& aTextNode2) {
+ // XXX How about the case that both text nodes are orphan nodes?
+ if (aTextNode1.GetParent() == aTextNode2.GetParent()) {
+ return true;
+ }
+
+ // I think that spellcheck should be available only in editable nodes.
+ // So, we also need to check whether they are in same editing host.
+ const Element* editableBlockElementOrInlineEditingHost1 =
+ HTMLEditUtils::GetAncestorElement(
+ aTextNode1,
+ HTMLEditUtils::ClosestEditableBlockElementOrInlineEditingHost,
+ BlockInlineCheck::UseHTMLDefaultStyle);
+ const Element* editableBlockElementOrInlineEditingHost2 =
+ HTMLEditUtils::GetAncestorElement(
+ aTextNode2,
+ HTMLEditUtils::ClosestEditableBlockElementOrInlineEditingHost,
+ BlockInlineCheck::UseHTMLDefaultStyle);
+ return editableBlockElementOrInlineEditingHost1 &&
+ editableBlockElementOrInlineEditingHost1 ==
+ editableBlockElementOrInlineEditingHost2;
+}
+
+Result<EditorRawDOMRangeInTexts, nsresult>
+TextServicesDocument::OffsetEntryArray::WillSetSelection(
+ uint32_t aOffsetInTextInBlock, uint32_t aLength) {
+ // Find start of selection in node offset terms:
+ EditorRawDOMPointInText newStart;
+ for (size_t i = 0; !newStart.IsSet() && i < Length(); i++) {
+ const UniquePtr<OffsetEntry>& entry = ElementAt(i);
+ LockOffsetEntryArrayLengthInDebugBuild(observer, *this);
+ if (entry->mIsValid) {
+ if (entry->mIsInsertedText) {
+ // Caret can only be placed at the end of an
+ // inserted text offset entry, if the offsets
+ // match exactly!
+ if (entry->mOffsetInTextInBlock == aOffsetInTextInBlock) {
+ newStart.Set(entry->mTextNode, entry->EndOffsetInTextNode());
+ }
+ } else if (aOffsetInTextInBlock >= entry->mOffsetInTextInBlock) {
+ bool foundEntry = false;
+ if (aOffsetInTextInBlock < entry->EndOffsetInTextInBlock()) {
+ foundEntry = true;
+ } else if (aOffsetInTextInBlock == entry->EndOffsetInTextInBlock()) {
+ // Peek after this entry to see if we have any
+ // inserted text entries belonging to the same
+ // entry->mTextNode. If so, we have to place the selection
+ // after it!
+ if (i + 1 < Length()) {
+ const UniquePtr<OffsetEntry>& nextEntry = ElementAt(i + 1);
+ LockOffsetEntryArrayLengthInDebugBuild(observer, *this);
+ if (!nextEntry->mIsValid ||
+ nextEntry->mOffsetInTextInBlock != aOffsetInTextInBlock) {
+ // Next offset entry isn't an exact match, so we'll
+ // just use the current entry.
+ foundEntry = true;
+ }
+ }
+ }
+
+ if (foundEntry) {
+ newStart.Set(entry->mTextNode, entry->mOffsetInTextNode +
+ aOffsetInTextInBlock -
+ entry->mOffsetInTextInBlock);
+ }
+ }
+
+ if (newStart.IsSet()) {
+ MOZ_DIAGNOSTIC_ASSERT(i < Length());
+ mSelection.Set(i, aOffsetInTextInBlock);
+ }
+ }
+ }
+
+ if (NS_WARN_IF(!newStart.IsSet())) {
+ return Err(NS_ERROR_FAILURE);
+ }
+
+ if (!aLength) {
+ mSelection.CollapseToStart();
+ return EditorRawDOMRangeInTexts(newStart);
+ }
+
+ // Find the end of the selection in node offset terms:
+ EditorRawDOMPointInText newEnd;
+ const uint32_t endOffset = aOffsetInTextInBlock + aLength;
+ for (uint32_t i = Length(); !newEnd.IsSet() && i > 0; i--) {
+ const UniquePtr<OffsetEntry>& entry = ElementAt(i - 1);
+ LockOffsetEntryArrayLengthInDebugBuild(observer, *this);
+ if (entry->mIsValid) {
+ if (entry->mIsInsertedText) {
+ if (entry->mOffsetInTextInBlock ==
+ (newEnd.IsSet() ? newEnd.Offset() : 0)) {
+ // If the selection ends on an inserted text offset entry,
+ // the selection includes the entire entry!
+ newEnd.Set(entry->mTextNode, entry->EndOffsetInTextNode());
+ }
+ } else if (entry->OffsetInTextInBlockIsInRangeOrEndOffset(endOffset)) {
+ newEnd.Set(entry->mTextNode, entry->mOffsetInTextNode + endOffset -
+ entry->mOffsetInTextInBlock);
+ }
+
+ if (newEnd.IsSet()) {
+ MOZ_DIAGNOSTIC_ASSERT(mSelection.StartIndex() < Length());
+ MOZ_DIAGNOSTIC_ASSERT(i - 1 < Length());
+ mSelection.Set(mSelection.StartIndex(), i - 1,
+ mSelection.StartOffsetInTextInBlock(), endOffset);
+ }
+ }
+ }
+
+ return newEnd.IsSet() ? EditorRawDOMRangeInTexts(newStart, newEnd)
+ : EditorRawDOMRangeInTexts(newStart);
+}
+
+nsresult TextServicesDocument::SetSelectionInternal(
+ uint32_t aOffsetInTextInBlock, uint32_t aLength, bool aDoUpdate) {
+ if (NS_WARN_IF(!mSelCon)) {
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ Result<EditorRawDOMRangeInTexts, nsresult> newSelectionRange =
+ mOffsetTable.WillSetSelection(aOffsetInTextInBlock, aLength);
+ if (newSelectionRange.isErr()) {
+ NS_WARNING(
+ "TextServicesDocument::OffsetEntryArray::WillSetSelection() failed");
+ return newSelectionRange.unwrapErr();
+ }
+
+ if (!aDoUpdate) {
+ return NS_OK;
+ }
+
+ // XXX: If we ever get a SetSelection() method in nsIEditor, we should
+ // use it.
+ RefPtr<Selection> selection =
+ mSelCon->GetSelection(nsISelectionController::SELECTION_NORMAL);
+ if (NS_WARN_IF(!selection)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ if (newSelectionRange.inspect().Collapsed()) {
+ nsresult rv =
+ selection->CollapseInLimiter(newSelectionRange.inspect().StartRef());
+ NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+ "Selection::CollapseInLimiter() failed");
+ return rv;
+ }
+
+ ErrorResult error;
+ selection->SetStartAndEndInLimiter(newSelectionRange.inspect().StartRef(),
+ newSelectionRange.inspect().EndRef(),
+ error);
+ NS_WARNING_ASSERTION(!error.Failed(),
+ "Selection::SetStartAndEndInLimiter() failed");
+ return error.StealNSResult();
+}
+
+nsresult TextServicesDocument::GetSelection(BlockSelectionStatus* aSelStatus,
+ uint32_t* aSelOffset,
+ uint32_t* aSelLength) {
+ NS_ENSURE_TRUE(aSelStatus && aSelOffset && aSelLength, NS_ERROR_NULL_POINTER);
+
+ *aSelStatus = BlockSelectionStatus::eBlockNotFound;
+ *aSelOffset = UINT32_MAX;
+ *aSelLength = UINT32_MAX;
+
+ NS_ENSURE_TRUE(mDocument && mSelCon, NS_ERROR_FAILURE);
+
+ if (mIteratorStatus == IteratorStatus::eDone) {
+ return NS_OK;
+ }
+
+ RefPtr<Selection> selection =
+ mSelCon->GetSelection(nsISelectionController::SELECTION_NORMAL);
+ NS_ENSURE_TRUE(selection, NS_ERROR_FAILURE);
+
+ if (selection->IsCollapsed()) {
+ return GetCollapsedSelection(aSelStatus, aSelOffset, aSelLength);
+ }
+
+ return GetUncollapsedSelection(aSelStatus, aSelOffset, aSelLength);
+}
+
+nsresult TextServicesDocument::GetCollapsedSelection(
+ BlockSelectionStatus* aSelStatus, uint32_t* aSelOffset,
+ uint32_t* aSelLength) {
+ RefPtr<Selection> selection =
+ mSelCon->GetSelection(nsISelectionController::SELECTION_NORMAL);
+ NS_ENSURE_TRUE(selection, NS_ERROR_FAILURE);
+
+ // The calling function should have done the GetIsCollapsed()
+ // check already. Just assume it's collapsed!
+ *aSelStatus = BlockSelectionStatus::eBlockOutside;
+ *aSelOffset = *aSelLength = UINT32_MAX;
+
+ const uint32_t tableCount = mOffsetTable.Length();
+ if (!tableCount) {
+ return NS_OK;
+ }
+
+ // Get pointers to the first and last offset entries
+ // in the table.
+
+ UniquePtr<OffsetEntry>& eStart = mOffsetTable[0];
+ UniquePtr<OffsetEntry>& eEnd =
+ tableCount > 1 ? mOffsetTable[tableCount - 1] : eStart;
+ LockOffsetEntryArrayLengthInDebugBuild(observer, mOffsetTable);
+
+ const uint32_t eStartOffset = eStart->mOffsetInTextNode;
+ const uint32_t eEndOffset = eEnd->EndOffsetInTextNode();
+
+ RefPtr<const nsRange> range = selection->GetRangeAt(0);
+ NS_ENSURE_STATE(range);
+
+ nsCOMPtr<nsINode> parent = range->GetStartContainer();
+ MOZ_ASSERT(parent);
+
+ uint32_t offset = range->StartOffset();
+
+ const Maybe<int32_t> e1s1 = nsContentUtils::ComparePoints(
+ eStart->mTextNode, eStartOffset, parent, offset);
+ const Maybe<int32_t> e2s1 = nsContentUtils::ComparePoints(
+ eEnd->mTextNode, eEndOffset, parent, offset);
+
+ if (MOZ_UNLIKELY(NS_WARN_IF(!e1s1) || NS_WARN_IF(!e2s1))) {
+ return NS_ERROR_FAILURE;
+ }
+
+ if (*e1s1 > 0 || *e2s1 < 0) {
+ // We're done if the caret is outside the current text block.
+ return NS_OK;
+ }
+
+ if (parent->IsText()) {
+ // Good news, the caret is in a text node. Look
+ // through the offset table for the entry that
+ // matches its parent and offset.
+
+ for (uint32_t i = 0; i < tableCount; i++) {
+ const UniquePtr<OffsetEntry>& entry = mOffsetTable[i];
+ LockOffsetEntryArrayLengthInDebugBuild(observer, mOffsetTable);
+ if (entry->mTextNode == parent->AsText() &&
+ entry->OffsetInTextNodeIsInRangeOrEndOffset(offset)) {
+ *aSelStatus = BlockSelectionStatus::eBlockContains;
+ *aSelOffset =
+ entry->mOffsetInTextInBlock + (offset - entry->mOffsetInTextNode);
+ *aSelLength = 0;
+ return NS_OK;
+ }
+ }
+
+ // If we get here, we didn't find a text node entry
+ // in our offset table that matched.
+ return NS_ERROR_FAILURE;
+ }
+
+ // The caret is in our text block, but it's positioned in some
+ // non-text node (ex. <b>). Create a range based on the start
+ // and end of the text block, then create an iterator based on
+ // this range, with its initial position set to the closest
+ // child of this non-text node. Then look for the closest text
+ // node.
+
+ range = nsRange::Create(eStart->mTextNode, eStartOffset, eEnd->mTextNode,
+ eEndOffset, IgnoreErrors());
+ if (NS_WARN_IF(!range)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ RefPtr<FilteredContentIterator> filteredIter;
+ nsresult rv =
+ CreateFilteredContentIterator(range, getter_AddRefs(filteredIter));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsIContent* saveNode;
+ if (parent->HasChildren()) {
+ // XXX: We need to make sure that all of parent's
+ // children are in the text block.
+
+ // If the parent has children, position the iterator
+ // on the child that is to the left of the offset.
+
+ nsIContent* content = range->GetChildAtStartOffset();
+ if (content && parent->GetFirstChild() != content) {
+ content = content->GetPreviousSibling();
+ }
+ NS_ENSURE_TRUE(content, NS_ERROR_FAILURE);
+
+ nsresult rv = filteredIter->PositionAt(content);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ saveNode = content;
+ } else {
+ // The parent has no children, so position the iterator
+ // on the parent.
+ NS_ENSURE_TRUE(parent->IsContent(), NS_ERROR_FAILURE);
+ nsCOMPtr<nsIContent> content = parent->AsContent();
+
+ nsresult rv = filteredIter->PositionAt(content);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ saveNode = content;
+ }
+
+ // Now iterate to the left, towards the beginning of
+ // the text block, to find the first text node you
+ // come across.
+
+ Text* textNode = nullptr;
+ for (; !filteredIter->IsDone(); filteredIter->Prev()) {
+ nsINode* current = filteredIter->GetCurrentNode();
+ if (current->IsText()) {
+ textNode = current->AsText();
+ break;
+ }
+ }
+
+ if (textNode) {
+ // We found a node, now set the offset to the end
+ // of the text node.
+ offset = textNode->TextLength();
+ } else {
+ // We should never really get here, but I'm paranoid.
+
+ // We didn't find a text node above, so iterate to
+ // the right, towards the end of the text block, looking
+ // for a text node.
+
+ nsresult rv = filteredIter->PositionAt(saveNode);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ textNode = nullptr;
+ for (; !filteredIter->IsDone(); filteredIter->Next()) {
+ nsINode* current = filteredIter->GetCurrentNode();
+ if (current->IsText()) {
+ textNode = current->AsText();
+ break;
+ }
+ }
+ NS_ENSURE_TRUE(textNode, NS_ERROR_FAILURE);
+
+ // We found a text node, so set the offset to
+ // the beginning of the node.
+ offset = 0;
+ }
+
+ for (size_t i = 0; i < tableCount; i++) {
+ const UniquePtr<OffsetEntry>& entry = mOffsetTable[i];
+ LockOffsetEntryArrayLengthInDebugBuild(observer, mOffsetTable);
+ if (entry->mTextNode == textNode &&
+ entry->OffsetInTextNodeIsInRangeOrEndOffset(offset)) {
+ *aSelStatus = BlockSelectionStatus::eBlockContains;
+ *aSelOffset =
+ entry->mOffsetInTextInBlock + (offset - entry->mOffsetInTextNode);
+ *aSelLength = 0;
+
+ // Now move the caret so that it is actually in the text node.
+ // We do this to keep things in sync.
+ //
+ // In most cases, the user shouldn't see any movement in the caret
+ // on screen.
+ return SetSelectionInternal(*aSelOffset, *aSelLength, true);
+ }
+ }
+
+ return NS_ERROR_FAILURE;
+}
+
+nsresult TextServicesDocument::GetUncollapsedSelection(
+ BlockSelectionStatus* aSelStatus, uint32_t* aSelOffset,
+ uint32_t* aSelLength) {
+ RefPtr<const nsRange> range;
+ RefPtr<Selection> selection =
+ mSelCon->GetSelection(nsISelectionController::SELECTION_NORMAL);
+ NS_ENSURE_TRUE(selection, NS_ERROR_FAILURE);
+
+ // It is assumed that the calling function has made sure that the
+ // selection is not collapsed, and that the input params to this
+ // method are initialized to some defaults.
+
+ nsCOMPtr<nsINode> startContainer, endContainer;
+
+ const size_t tableCount = mOffsetTable.Length();
+
+ // Get pointers to the first and last offset entries
+ // in the table.
+
+ UniquePtr<OffsetEntry>& eStart = mOffsetTable[0];
+ UniquePtr<OffsetEntry>& eEnd =
+ tableCount > 1 ? mOffsetTable[tableCount - 1] : eStart;
+ LockOffsetEntryArrayLengthInDebugBuild(observer, mOffsetTable);
+
+ const uint32_t eStartOffset = eStart->mOffsetInTextNode;
+ const uint32_t eEndOffset = eEnd->EndOffsetInTextNode();
+
+ const uint32_t rangeCount = selection->RangeCount();
+ MOZ_ASSERT(rangeCount);
+
+ // Find the first range in the selection that intersects
+ // the current text block.
+ Maybe<int32_t> e1s2;
+ Maybe<int32_t> e2s1;
+ uint32_t startOffset, endOffset;
+ for (const uint32_t i : IntegerRange(rangeCount)) {
+ MOZ_ASSERT(selection->RangeCount() == rangeCount);
+ range = selection->GetRangeAt(i);
+ if (MOZ_UNLIKELY(NS_WARN_IF(!range))) {
+ return NS_ERROR_FAILURE;
+ }
+
+ nsresult rv =
+ GetRangeEndPoints(range, getter_AddRefs(startContainer), &startOffset,
+ getter_AddRefs(endContainer), &endOffset);
+
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ e1s2 = nsContentUtils::ComparePoints(eStart->mTextNode, eStartOffset,
+ endContainer, endOffset);
+ if (NS_WARN_IF(!e1s2)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ e2s1 = nsContentUtils::ComparePoints(eEnd->mTextNode, eEndOffset,
+ startContainer, startOffset);
+ if (NS_WARN_IF(!e2s1)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // Break out of the loop if the text block intersects the current range.
+
+ if (*e1s2 <= 0 && *e2s1 >= 0) {
+ break;
+ }
+ }
+
+ // We're done if we didn't find an intersecting range.
+
+ if (rangeCount < 1 || *e1s2 > 0 || *e2s1 < 0) {
+ *aSelStatus = BlockSelectionStatus::eBlockOutside;
+ *aSelOffset = *aSelLength = UINT32_MAX;
+ return NS_OK;
+ }
+
+ // Now that we have an intersecting range, find out more info:
+ const Maybe<int32_t> e1s1 = nsContentUtils::ComparePoints(
+ eStart->mTextNode, eStartOffset, startContainer, startOffset);
+ if (NS_WARN_IF(!e1s1)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ const Maybe<int32_t> e2s2 = nsContentUtils::ComparePoints(
+ eEnd->mTextNode, eEndOffset, endContainer, endOffset);
+ if (NS_WARN_IF(!e2s2)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ if (rangeCount > 1) {
+ // There are multiple selection ranges, we only deal
+ // with the first one that intersects the current,
+ // text block, so mark this a as a partial.
+ *aSelStatus = BlockSelectionStatus::eBlockPartial;
+ } else if (*e1s1 > 0 && *e2s2 < 0) {
+ // The range extends beyond the start and
+ // end of the current text block.
+ *aSelStatus = BlockSelectionStatus::eBlockInside;
+ } else if (*e1s1 <= 0 && *e2s2 >= 0) {
+ // The current text block contains the entire
+ // range.
+ *aSelStatus = BlockSelectionStatus::eBlockContains;
+ } else {
+ // The range partially intersects the block.
+ *aSelStatus = BlockSelectionStatus::eBlockPartial;
+ }
+
+ // Now create a range based on the intersection of the
+ // text block and range:
+
+ nsCOMPtr<nsINode> p1, p2;
+ uint32_t o1, o2;
+
+ // The start of the range will be the rightmost
+ // start node.
+
+ if (*e1s1 >= 0) {
+ p1 = eStart->mTextNode;
+ o1 = eStartOffset;
+ } else {
+ p1 = startContainer;
+ o1 = startOffset;
+ }
+
+ // The end of the range will be the leftmost
+ // end node.
+
+ if (*e2s2 <= 0) {
+ p2 = eEnd->mTextNode;
+ o2 = eEndOffset;
+ } else {
+ p2 = endContainer;
+ o2 = endOffset;
+ }
+
+ range = nsRange::Create(p1, o1, p2, o2, IgnoreErrors());
+ if (NS_WARN_IF(!range)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // Now iterate over this range to figure out the selection's
+ // block offset and length.
+
+ RefPtr<FilteredContentIterator> filteredIter;
+ nsresult rv =
+ CreateFilteredContentIterator(range, getter_AddRefs(filteredIter));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Find the first text node in the range.
+ nsCOMPtr<nsIContent> content;
+ filteredIter->First();
+ if (!p1->IsText()) {
+ bool found = false;
+ for (; !filteredIter->IsDone(); filteredIter->Next()) {
+ nsINode* node = filteredIter->GetCurrentNode();
+ if (node->IsText()) {
+ p1 = node->AsText();
+ o1 = 0;
+ found = true;
+ break;
+ }
+ }
+ NS_ENSURE_TRUE(found, NS_ERROR_FAILURE);
+ }
+
+ // Find the last text node in the range.
+ filteredIter->Last();
+ if (!p2->IsText()) {
+ bool found = false;
+ for (; !filteredIter->IsDone(); filteredIter->Prev()) {
+ nsINode* node = filteredIter->GetCurrentNode();
+ if (node->IsText()) {
+ p2 = node->AsText();
+ o2 = p2->AsText()->Length();
+ found = true;
+
+ break;
+ }
+ }
+ NS_ENSURE_TRUE(found, NS_ERROR_FAILURE);
+ }
+
+ bool found = false;
+ *aSelLength = 0;
+
+ for (size_t i = 0; i < tableCount; i++) {
+ const UniquePtr<OffsetEntry>& entry = mOffsetTable[i];
+ LockOffsetEntryArrayLengthInDebugBuild(observer, mOffsetTable);
+ if (!found) {
+ if (entry->mTextNode == p1.get() &&
+ entry->OffsetInTextNodeIsInRangeOrEndOffset(o1)) {
+ *aSelOffset =
+ entry->mOffsetInTextInBlock + (o1 - entry->mOffsetInTextNode);
+ if (p1 == p2 && entry->OffsetInTextNodeIsInRangeOrEndOffset(o2)) {
+ // The start and end of the range are in the same offset
+ // entry. Calculate the length of the range then we're done.
+ *aSelLength = o2 - o1;
+ break;
+ }
+ // Add the length of the sub string in this offset entry
+ // that follows the start of the range.
+ *aSelLength = entry->EndOffsetInTextNode() - o1;
+ found = true;
+ }
+ } else { // Found.
+ if (entry->mTextNode == p2.get() &&
+ entry->OffsetInTextNodeIsInRangeOrEndOffset(o2)) {
+ // We found the end of the range. Calculate the length of the
+ // sub string that is before the end of the range, then we're done.
+ *aSelLength += o2 - entry->mOffsetInTextNode;
+ break;
+ }
+ // The entire entry must be in the range.
+ *aSelLength += entry->mLength;
+ }
+ }
+
+ return NS_OK;
+}
+
+// static
+nsresult TextServicesDocument::GetRangeEndPoints(
+ const AbstractRange* aAbstractRange, nsINode** aStartContainer,
+ uint32_t* aStartOffset, nsINode** aEndContainer, uint32_t* aEndOffset) {
+ if (NS_WARN_IF(!aAbstractRange) || NS_WARN_IF(!aStartContainer) ||
+ NS_WARN_IF(!aEndContainer) || NS_WARN_IF(!aEndOffset)) {
+ return NS_ERROR_INVALID_ARG;
+ }
+
+ nsCOMPtr<nsINode> startContainer = aAbstractRange->GetStartContainer();
+ if (NS_WARN_IF(!startContainer)) {
+ return NS_ERROR_FAILURE;
+ }
+ nsCOMPtr<nsINode> endContainer = aAbstractRange->GetEndContainer();
+ if (NS_WARN_IF(!endContainer)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ startContainer.forget(aStartContainer);
+ endContainer.forget(aEndContainer);
+ *aStartOffset = aAbstractRange->StartOffset();
+ *aEndOffset = aAbstractRange->EndOffset();
+ return NS_OK;
+}
+
+// static
+nsresult TextServicesDocument::FirstTextNode(
+ FilteredContentIterator* aFilteredIter, IteratorStatus* aIteratorStatus) {
+ if (aIteratorStatus) {
+ *aIteratorStatus = IteratorStatus::eDone;
+ }
+
+ for (aFilteredIter->First(); !aFilteredIter->IsDone();
+ aFilteredIter->Next()) {
+ if (aFilteredIter->GetCurrentNode()->NodeType() == nsINode::TEXT_NODE) {
+ if (aIteratorStatus) {
+ *aIteratorStatus = IteratorStatus::eValid;
+ }
+ break;
+ }
+ }
+
+ return NS_OK;
+}
+
+// static
+nsresult TextServicesDocument::LastTextNode(
+ FilteredContentIterator* aFilteredIter, IteratorStatus* aIteratorStatus) {
+ if (aIteratorStatus) {
+ *aIteratorStatus = IteratorStatus::eDone;
+ }
+
+ for (aFilteredIter->Last(); !aFilteredIter->IsDone(); aFilteredIter->Prev()) {
+ if (aFilteredIter->GetCurrentNode()->NodeType() == nsINode::TEXT_NODE) {
+ if (aIteratorStatus) {
+ *aIteratorStatus = IteratorStatus::eValid;
+ }
+ break;
+ }
+ }
+
+ return NS_OK;
+}
+
+// static
+nsresult TextServicesDocument::FirstTextNodeInCurrentBlock(
+ FilteredContentIterator* aFilteredIter) {
+ NS_ENSURE_TRUE(aFilteredIter, NS_ERROR_NULL_POINTER);
+
+ ClearDidSkip(aFilteredIter);
+
+ // Walk backwards over adjacent text nodes until
+ // we hit a block boundary:
+ RefPtr<Text> lastTextNode;
+ while (!aFilteredIter->IsDone()) {
+ nsCOMPtr<nsIContent> content =
+ aFilteredIter->GetCurrentNode()->IsContent()
+ ? aFilteredIter->GetCurrentNode()->AsContent()
+ : nullptr;
+ // We don't observe layout updates, therefore, we should consider whether
+ // block or inline only with the default definition of the element.
+ if (lastTextNode && content &&
+ (HTMLEditUtils::IsBlockElement(*content,
+ BlockInlineCheck::UseHTMLDefaultStyle) ||
+ content->IsHTMLElement(nsGkAtoms::br))) {
+ break;
+ }
+ if (content && content->IsText()) {
+ if (lastTextNode && !TextServicesDocument::HasSameBlockNodeParent(
+ *content->AsText(), *lastTextNode)) {
+ // We're done, the current text node is in a
+ // different block.
+ break;
+ }
+ lastTextNode = content->AsText();
+ }
+
+ aFilteredIter->Prev();
+
+ if (DidSkip(aFilteredIter)) {
+ break;
+ }
+ }
+
+ if (lastTextNode) {
+ aFilteredIter->PositionAt(lastTextNode);
+ }
+
+ // XXX: What should we return if last is null?
+
+ return NS_OK;
+}
+
+// static
+nsresult TextServicesDocument::FirstTextNodeInPrevBlock(
+ FilteredContentIterator* aFilteredIter) {
+ NS_ENSURE_TRUE(aFilteredIter, NS_ERROR_NULL_POINTER);
+
+ // XXX: What if mFilteredIter is not currently on a text node?
+
+ // Make sure mFilteredIter is pointing to the first text node in the
+ // current block:
+
+ nsresult rv = FirstTextNodeInCurrentBlock(aFilteredIter);
+
+ NS_ENSURE_SUCCESS(rv, NS_ERROR_FAILURE);
+
+ // Point mFilteredIter to the first node before the first text node:
+
+ aFilteredIter->Prev();
+
+ if (aFilteredIter->IsDone()) {
+ return NS_ERROR_FAILURE;
+ }
+
+ // Now find the first text node of the next block:
+
+ return FirstTextNodeInCurrentBlock(aFilteredIter);
+}
+
+// static
+nsresult TextServicesDocument::FirstTextNodeInNextBlock(
+ FilteredContentIterator* aFilteredIter) {
+ bool crossedBlockBoundary = false;
+
+ NS_ENSURE_TRUE(aFilteredIter, NS_ERROR_NULL_POINTER);
+
+ ClearDidSkip(aFilteredIter);
+
+ RefPtr<Text> previousTextNode;
+ while (!aFilteredIter->IsDone()) {
+ if (nsCOMPtr<nsIContent> content =
+ aFilteredIter->GetCurrentNode()->IsContent()
+ ? aFilteredIter->GetCurrentNode()->AsContent()
+ : nullptr) {
+ if (content->IsText()) {
+ if (crossedBlockBoundary ||
+ (previousTextNode && !TextServicesDocument::HasSameBlockNodeParent(
+ *previousTextNode, *content->AsText()))) {
+ break;
+ }
+ previousTextNode = content->AsText();
+ }
+ // We don't observe layout updates, therefore, we should consider whether
+ // block or inline only with the default definition of the element.
+ else if (!crossedBlockBoundary &&
+ (HTMLEditUtils::IsBlockElement(
+ *content, BlockInlineCheck::UseHTMLDefaultStyle) ||
+ content->IsHTMLElement(nsGkAtoms::br))) {
+ crossedBlockBoundary = true;
+ }
+ }
+
+ aFilteredIter->Next();
+
+ if (!crossedBlockBoundary && DidSkip(aFilteredIter)) {
+ crossedBlockBoundary = true;
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult TextServicesDocument::GetFirstTextNodeInPrevBlock(
+ nsIContent** aContent) {
+ NS_ENSURE_TRUE(aContent, NS_ERROR_NULL_POINTER);
+
+ *aContent = 0;
+
+ // Save the iterator's current content node so we can restore
+ // it when we are done:
+
+ nsINode* node = mFilteredIter->GetCurrentNode();
+
+ nsresult rv = FirstTextNodeInPrevBlock(mFilteredIter);
+
+ if (NS_FAILED(rv)) {
+ // Try to restore the iterator before returning.
+ mFilteredIter->PositionAt(node);
+ return rv;
+ }
+
+ if (!mFilteredIter->IsDone()) {
+ nsCOMPtr<nsIContent> current =
+ mFilteredIter->GetCurrentNode()->IsContent()
+ ? mFilteredIter->GetCurrentNode()->AsContent()
+ : nullptr;
+ current.forget(aContent);
+ }
+
+ // Restore the iterator:
+
+ return mFilteredIter->PositionAt(node);
+}
+
+nsresult TextServicesDocument::GetFirstTextNodeInNextBlock(
+ nsIContent** aContent) {
+ NS_ENSURE_TRUE(aContent, NS_ERROR_NULL_POINTER);
+
+ *aContent = 0;
+
+ // Save the iterator's current content node so we can restore
+ // it when we are done:
+
+ nsINode* node = mFilteredIter->GetCurrentNode();
+
+ nsresult rv = FirstTextNodeInNextBlock(mFilteredIter);
+
+ if (NS_FAILED(rv)) {
+ // Try to restore the iterator before returning.
+ mFilteredIter->PositionAt(node);
+ return rv;
+ }
+
+ if (!mFilteredIter->IsDone()) {
+ nsCOMPtr<nsIContent> current =
+ mFilteredIter->GetCurrentNode()->IsContent()
+ ? mFilteredIter->GetCurrentNode()->AsContent()
+ : nullptr;
+ current.forget(aContent);
+ }
+
+ // Restore the iterator:
+ return mFilteredIter->PositionAt(node);
+}
+
+Result<TextServicesDocument::IteratorStatus, nsresult>
+TextServicesDocument::OffsetEntryArray::Init(
+ FilteredContentIterator& aFilteredIter, IteratorStatus aIteratorStatus,
+ nsRange* aIterRange, nsAString* aAllTextInBlock /* = nullptr */) {
+ Clear();
+
+ if (aAllTextInBlock) {
+ aAllTextInBlock->Truncate();
+ }
+
+ if (aIteratorStatus == IteratorStatus::eDone) {
+ return IteratorStatus::eDone;
+ }
+
+ // If we have an aIterRange, retrieve the endpoints so
+ // they can be used in the while loop below to trim entries
+ // for text nodes that are partially selected by aIterRange.
+
+ nsCOMPtr<nsINode> rngStartNode, rngEndNode;
+ uint32_t rngStartOffset = 0, rngEndOffset = 0;
+ if (aIterRange) {
+ nsresult rv = TextServicesDocument::GetRangeEndPoints(
+ aIterRange, getter_AddRefs(rngStartNode), &rngStartOffset,
+ getter_AddRefs(rngEndNode), &rngEndOffset);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("TextServicesDocument::GetRangeEndPoints() failed");
+ return Err(rv);
+ }
+ }
+
+ // The text service could have added text nodes to the beginning
+ // of the current block and called this method again. Make sure
+ // we really are at the beginning of the current block:
+
+ nsresult rv =
+ TextServicesDocument::FirstTextNodeInCurrentBlock(&aFilteredIter);
+ if (NS_FAILED(rv)) {
+ NS_WARNING("TextServicesDocument::FirstTextNodeInCurrentBlock() failed");
+ return Err(rv);
+ }
+
+ TextServicesDocument::ClearDidSkip(&aFilteredIter);
+
+ uint32_t offset = 0;
+ RefPtr<Text> firstTextNode, previousTextNode;
+ while (!aFilteredIter.IsDone()) {
+ if (nsCOMPtr<nsIContent> content =
+ aFilteredIter.GetCurrentNode()->IsContent()
+ ? aFilteredIter.GetCurrentNode()->AsContent()
+ : nullptr) {
+ // We don't observe layout updates, therefore, we should consider whether
+ // block or inline only with the default definition of the element.
+ if (HTMLEditUtils::IsBlockElement(
+ *content, BlockInlineCheck::UseHTMLDefaultStyle) ||
+ content->IsHTMLElement(nsGkAtoms::br)) {
+ break;
+ }
+ if (content->IsText()) {
+ if (previousTextNode && !TextServicesDocument::HasSameBlockNodeParent(
+ *previousTextNode, *content->AsText())) {
+ break;
+ }
+
+ nsString str;
+ content->AsText()->GetNodeValue(str);
+
+ // Add an entry for this text node into the offset table:
+
+ UniquePtr<OffsetEntry>& entry = *AppendElement(
+ MakeUnique<OffsetEntry>(*content->AsText(), offset, str.Length()));
+ LockOffsetEntryArrayLengthInDebugBuild(observer, *this);
+
+ // If one or both of the endpoints of the iteration range
+ // are in the text node for this entry, make sure the entry
+ // only accounts for the portion of the text node that is
+ // in the range.
+
+ uint32_t startOffset = 0;
+ uint32_t endOffset = str.Length();
+ bool adjustStr = false;
+
+ if (entry->mTextNode == rngStartNode) {
+ entry->mOffsetInTextNode = startOffset = rngStartOffset;
+ adjustStr = true;
+ }
+
+ if (entry->mTextNode == rngEndNode) {
+ endOffset = rngEndOffset;
+ adjustStr = true;
+ }
+
+ if (adjustStr) {
+ entry->mLength = endOffset - startOffset;
+ str = Substring(str, startOffset, entry->mLength);
+ }
+
+ offset += str.Length();
+
+ if (aAllTextInBlock) {
+ // Append the text node's string to the output string:
+ if (!firstTextNode) {
+ *aAllTextInBlock = str;
+ } else {
+ *aAllTextInBlock += str;
+ }
+ }
+
+ previousTextNode = content->AsText();
+
+ if (!firstTextNode) {
+ firstTextNode = content->AsText();
+ }
+ }
+ }
+
+ aFilteredIter.Next();
+
+ if (TextServicesDocument::DidSkip(&aFilteredIter)) {
+ break;
+ }
+ }
+
+ if (firstTextNode) {
+ // Always leave the iterator pointing at the first
+ // text node of the current block!
+ aFilteredIter.PositionAt(firstTextNode);
+ return aIteratorStatus;
+ }
+
+ // If we never ran across a text node, the iterator
+ // might have been pointing to something invalid to
+ // begin with.
+ return IteratorStatus::eDone;
+}
+
+void TextServicesDocument::OffsetEntryArray::RemoveInvalidElements() {
+ for (size_t i = 0; i < Length();) {
+ if (ElementAt(i)->mIsValid) {
+ i++;
+ continue;
+ }
+
+ RemoveElementAt(i);
+ if (!mSelection.IsSet()) {
+ continue;
+ }
+ if (mSelection.StartIndex() == i) {
+ NS_ASSERTION(false, "What should we do in this case?");
+ mSelection.Reset();
+ } else if (mSelection.StartIndex() > i) {
+ MOZ_DIAGNOSTIC_ASSERT(mSelection.StartIndex() - 1 < Length());
+ MOZ_DIAGNOSTIC_ASSERT(mSelection.EndIndex() - 1 < Length());
+ mSelection.SetIndexes(mSelection.StartIndex() - 1,
+ mSelection.EndIndex() - 1);
+ } else if (mSelection.EndIndex() >= i) {
+ MOZ_DIAGNOSTIC_ASSERT(mSelection.EndIndex() - 1 < Length());
+ mSelection.SetIndexes(mSelection.StartIndex(), mSelection.EndIndex() - 1);
+ }
+ }
+}
+
+nsresult TextServicesDocument::OffsetEntryArray::SplitElementAt(
+ size_t aIndex, uint32_t aOffsetInTextNode) {
+ OffsetEntry* leftEntry = ElementAt(aIndex).get();
+ MOZ_ASSERT(leftEntry);
+ NS_ASSERTION((aOffsetInTextNode > 0), "aOffsetInTextNode == 0");
+ NS_ASSERTION((aOffsetInTextNode < leftEntry->mLength),
+ "aOffsetInTextNode >= mLength");
+
+ if (aOffsetInTextNode < 1 || aOffsetInTextNode >= leftEntry->mLength) {
+ return NS_ERROR_FAILURE;
+ }
+
+ const uint32_t oldLength = leftEntry->mLength - aOffsetInTextNode;
+
+ // XXX(Bug 1631371) Check if this should use a fallible operation as it
+ // pretended earlier.
+ UniquePtr<OffsetEntry>& rightEntry = *InsertElementAt(
+ aIndex + 1,
+ MakeUnique<OffsetEntry>(leftEntry->mTextNode,
+ leftEntry->mOffsetInTextInBlock + oldLength,
+ aOffsetInTextNode));
+ LockOffsetEntryArrayLengthInDebugBuild(observer, *this);
+ leftEntry->mLength = oldLength;
+ rightEntry->mOffsetInTextNode = leftEntry->mOffsetInTextNode + oldLength;
+
+ return NS_OK;
+}
+
+Maybe<size_t> TextServicesDocument::OffsetEntryArray::FirstIndexOf(
+ const Text& aTextNode) const {
+ for (size_t i = 0; i < Length(); i++) {
+ if (ElementAt(i)->mTextNode == &aTextNode) {
+ return Some(i);
+ }
+ }
+ return Nothing();
+}
+
+// Spellchecker code has this. See bug 211343
+#define IS_NBSP_CHAR(c) (((unsigned char)0xa0) == (c))
+
+Result<EditorDOMRangeInTexts, nsresult>
+TextServicesDocument::OffsetEntryArray::FindWordRange(
+ nsAString& aAllTextInBlock, const EditorRawDOMPoint& aStartPointToScan) {
+ MOZ_ASSERT(aStartPointToScan.IsInTextNode());
+ // It's assumed that aNode is a text node. The first thing
+ // we do is get its index in the offset table so we can
+ // calculate the dom point's string offset.
+ Maybe<size_t> maybeEntryIndex =
+ FirstIndexOf(*aStartPointToScan.ContainerAs<Text>());
+ if (NS_WARN_IF(maybeEntryIndex.isNothing())) {
+ NS_WARNING(
+ "TextServicesDocument::OffsetEntryArray::FirstIndexOf() didn't find "
+ "entries");
+ return Err(NS_ERROR_FAILURE);
+ }
+
+ // Next we map offset into a string offset.
+
+ const UniquePtr<OffsetEntry>& entry = ElementAt(*maybeEntryIndex);
+ LockOffsetEntryArrayLengthInDebugBuild(observer, *this);
+ uint32_t strOffset = entry->mOffsetInTextInBlock +
+ aStartPointToScan.Offset() - entry->mOffsetInTextNode;
+
+ // Now we use the word breaker to find the beginning and end
+ // of the word from our calculated string offset.
+
+ const char16_t* str = aAllTextInBlock.BeginReading();
+ MOZ_ASSERT(strOffset <= aAllTextInBlock.Length(),
+ "The string offset shouldn't be greater than the string length!");
+
+ intl::WordRange res = intl::WordBreaker::FindWord(aAllTextInBlock, strOffset);
+
+ // Strip out the NBSPs at the ends
+ while (res.mBegin <= res.mEnd && IS_NBSP_CHAR(str[res.mBegin])) {
+ res.mBegin++;
+ }
+ if (str[res.mEnd] == static_cast<char16_t>(0x20)) {
+ uint32_t realEndWord = res.mEnd - 1;
+ while (realEndWord > res.mBegin && IS_NBSP_CHAR(str[realEndWord])) {
+ realEndWord--;
+ }
+ if (realEndWord < res.mEnd - 1) {
+ res.mEnd = realEndWord + 1;
+ }
+ }
+
+ // Now that we have the string offsets for the beginning
+ // and end of the word, run through the offset table and
+ // convert them back into dom points.
+
+ EditorDOMPointInText wordStart, wordEnd;
+ size_t lastIndex = Length() - 1;
+ for (size_t i = 0; i <= lastIndex; i++) {
+ // Check to see if res.mBegin is within the range covered
+ // by this entry. Note that if res.mBegin is after the last
+ // character covered by this entry, we will use the next
+ // entry if there is one.
+ const UniquePtr<OffsetEntry>& entry = ElementAt(i);
+ LockOffsetEntryArrayLengthInDebugBuild(observer, *this);
+ if (entry->mOffsetInTextInBlock <= res.mBegin &&
+ (res.mBegin < entry->EndOffsetInTextInBlock() ||
+ (res.mBegin == entry->EndOffsetInTextInBlock() && i == lastIndex))) {
+ wordStart.Set(entry->mTextNode, entry->mOffsetInTextNode + res.mBegin -
+ entry->mOffsetInTextInBlock);
+ }
+
+ // Check to see if res.mEnd is within the range covered
+ // by this entry.
+ if (entry->mOffsetInTextInBlock <= res.mEnd &&
+ res.mEnd <= entry->EndOffsetInTextInBlock()) {
+ if (res.mBegin == res.mEnd &&
+ res.mEnd == entry->EndOffsetInTextInBlock() && i != lastIndex) {
+ // Wait for the next round so that we use the same entry
+ // we did for aWordStartNode.
+ continue;
+ }
+
+ wordEnd.Set(entry->mTextNode, entry->mOffsetInTextNode + res.mEnd -
+ entry->mOffsetInTextInBlock);
+ break;
+ }
+ }
+
+ return EditorDOMRangeInTexts(wordStart, wordEnd);
+}
+
+/**
+ * nsIEditActionListener implementation:
+ * Don't implement the behavior directly here. The methods won't be called
+ * if the instance is created for inline spell checker created for editor.
+ * If you need to listen a new edit action, you need to add similar
+ * non-virtual method and you need to call it from EditorBase directly.
+ */
+
+NS_IMETHODIMP
+TextServicesDocument::DidDeleteNode(nsINode* aChild, nsresult aResult) {
+ if (NS_WARN_IF(NS_FAILED(aResult)) || NS_WARN_IF(!aChild) ||
+ !aChild->IsContent()) {
+ return NS_OK;
+ }
+ DidDeleteContent(*aChild->AsContent());
+ return NS_OK;
+}
+
+NS_IMETHODIMP TextServicesDocument::DidJoinContents(
+ const EditorRawDOMPoint& aJoinedPoint, const nsINode* aRemovedNode) {
+ if (MOZ_UNLIKELY(NS_WARN_IF(!aJoinedPoint.IsSetAndValid()) ||
+ NS_WARN_IF(!aRemovedNode->IsContent()))) {
+ return NS_OK;
+ }
+ DidJoinContents(aJoinedPoint, *aRemovedNode->AsContent());
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+TextServicesDocument::DidInsertText(CharacterData* aTextNode, int32_t aOffset,
+ const nsAString& aString,
+ nsresult aResult) {
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+TextServicesDocument::WillDeleteText(CharacterData* aTextNode, int32_t aOffset,
+ int32_t aLength) {
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+TextServicesDocument::WillDeleteRanges(
+ const nsTArray<RefPtr<nsRange>>& aRangesToDelete) {
+ return NS_OK;
+}
+
+#undef LockOffsetEntryArrayLengthInDebugBuild
+
+} // namespace mozilla
diff --git a/editor/spellchecker/TextServicesDocument.h b/editor/spellchecker/TextServicesDocument.h
new file mode 100644
index 0000000000..0b72bda977
--- /dev/null
+++ b/editor/spellchecker/TextServicesDocument.h
@@ -0,0 +1,436 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef mozilla_TextServicesDocument_h
+#define mozilla_TextServicesDocument_h
+
+#include "mozilla/Maybe.h"
+#include "mozilla/UniquePtr.h"
+#include "nsCOMPtr.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsIEditActionListener.h"
+#include "nsISupportsImpl.h"
+#include "nsStringFwd.h"
+#include "nsTArray.h"
+#include "nscore.h"
+
+class nsIContent;
+class nsIEditor;
+class nsINode;
+class nsISelectionController;
+class nsRange;
+
+namespace mozilla {
+
+class EditorBase;
+class FilteredContentIterator;
+class OffsetEntry;
+
+namespace dom {
+class AbstractRange;
+class Document;
+class Element;
+class StaticRange;
+}; // namespace dom
+
+/**
+ * The TextServicesDocument presents the document in as a bunch of flattened
+ * text blocks. Each text block can be retrieved as an nsString.
+ */
+class TextServicesDocument final : public nsIEditActionListener {
+ private:
+ enum class IteratorStatus : uint8_t {
+ // No iterator (I), or iterator doesn't point to anything valid.
+ eDone = 0,
+ // I points to first text node (TN) in current block (CB).
+ eValid,
+ // No TN in CB, I points to first TN in prev block.
+ ePrev,
+ // No TN in CB, I points to first TN in next block.
+ eNext,
+ };
+
+ class OffsetEntryArray final : public nsTArray<UniquePtr<OffsetEntry>> {
+ public:
+ /**
+ * Init() initializes this array with aFilteredIter.
+ *
+ * @param[in] aIterRange Can be nullptr.
+ * @param[out] aAllTextInBlock
+ * Returns all text in the block.
+ */
+ Result<IteratorStatus, nsresult> Init(
+ FilteredContentIterator& aFilteredIter, IteratorStatus aIteratorStatus,
+ nsRange* aIterRange, nsAString* aAllTextInBlock = nullptr);
+
+ /**
+ * Returns index of first `OffsetEntry` which manages aTextNode.
+ */
+ Maybe<size_t> FirstIndexOf(const dom::Text& aTextNode) const;
+
+ /**
+ * FindWordRange() returns a word range starting from aStartPointToScan
+ * in aAllTextInBlock.
+ */
+ Result<EditorDOMRangeInTexts, nsresult> FindWordRange(
+ nsAString& aAllTextInBlock, const EditorRawDOMPoint& aStartPointToScan);
+
+ /**
+ * SplitElementAt() splits an `OffsetEntry` at aIndex if aOffsetInTextNode
+ * is middle of the range in the text node.
+ *
+ * @param aIndex Index of the entry which you want to split.
+ * @param aOffsetInTextNode
+ * Offset in the text node. I.e., the offset should be
+ * greater than 0 and less than `mLength`.
+ */
+ nsresult SplitElementAt(size_t aIndex, uint32_t aOffsetInTextNode);
+
+ /**
+ * Remove all `OffsetEntry` elements whose `mIsValid` is set to false.
+ */
+ void RemoveInvalidElements();
+
+ /**
+ * Called when non-collapsed selection will be deleted.
+ */
+ nsresult WillDeleteSelection();
+
+ /**
+ * Called when non-collapsed selection is deleteded.
+ */
+ OffsetEntry* DidDeleteSelection();
+
+ /**
+ * Called when aInsertedText is inserted.
+ */
+ MOZ_CAN_RUN_SCRIPT nsresult DidInsertText(dom::Selection* aSelection,
+ const nsAString& aInsertedString);
+
+ /**
+ * Called when selection range will be applied to the DOM Selection.
+ */
+ Result<EditorRawDOMRangeInTexts, nsresult> WillSetSelection(
+ uint32_t aOffsetInTextInBlock, uint32_t aLength);
+
+ class Selection final {
+ public:
+ size_t StartIndex() const {
+ MOZ_ASSERT(IsIndexesSet());
+ return *mStartIndex;
+ }
+ size_t EndIndex() const {
+ MOZ_ASSERT(IsIndexesSet());
+ return *mEndIndex;
+ }
+
+ uint32_t StartOffsetInTextInBlock() const {
+ MOZ_ASSERT(IsSet());
+ return *mStartOffsetInTextInBlock;
+ }
+ uint32_t EndOffsetInTextInBlock() const {
+ MOZ_ASSERT(IsSet());
+ return *mEndOffsetInTextInBlock;
+ }
+ uint32_t LengthInTextInBlock() const {
+ MOZ_ASSERT(IsSet());
+ return *mEndOffsetInTextInBlock - *mStartOffsetInTextInBlock;
+ }
+
+ bool IsCollapsed() {
+ return !IsSet() || (IsInSameElement() && StartOffsetInTextInBlock() ==
+ EndOffsetInTextInBlock());
+ }
+
+ bool IsIndexesSet() const {
+ return mStartIndex.isSome() && mEndIndex.isSome();
+ }
+ bool IsSet() const {
+ return IsIndexesSet() && mStartOffsetInTextInBlock.isSome() &&
+ mEndOffsetInTextInBlock.isSome();
+ }
+ bool IsInSameElement() const {
+ return IsIndexesSet() && StartIndex() == EndIndex();
+ }
+
+ void Reset() {
+ mStartIndex.reset();
+ mEndIndex.reset();
+ mStartOffsetInTextInBlock.reset();
+ mEndOffsetInTextInBlock.reset();
+ }
+ void SetIndex(size_t aIndex) { mEndIndex = mStartIndex = Some(aIndex); }
+ void Set(size_t aIndex, uint32_t aOffsetInTextInBlock) {
+ mEndIndex = mStartIndex = Some(aIndex);
+ mStartOffsetInTextInBlock = mEndOffsetInTextInBlock =
+ Some(aOffsetInTextInBlock);
+ }
+ void SetIndexes(size_t aStartIndex, size_t aEndIndex) {
+ MOZ_DIAGNOSTIC_ASSERT(aStartIndex <= aEndIndex);
+ mStartIndex = Some(aStartIndex);
+ mEndIndex = Some(aEndIndex);
+ }
+ void Set(size_t aStartIndex, size_t aEndIndex,
+ uint32_t aStartOffsetInTextInBlock,
+ uint32_t aEndOffsetInTextInBlock) {
+ MOZ_DIAGNOSTIC_ASSERT(aStartIndex <= aEndIndex);
+ mStartIndex = Some(aStartIndex);
+ mEndIndex = Some(aEndIndex);
+ mStartOffsetInTextInBlock = Some(aStartOffsetInTextInBlock);
+ mEndOffsetInTextInBlock = Some(aEndOffsetInTextInBlock);
+ }
+
+ void CollapseToStart() {
+ MOZ_ASSERT(mStartIndex.isSome());
+ MOZ_ASSERT(mStartOffsetInTextInBlock.isSome());
+ mEndIndex = mStartIndex;
+ mEndOffsetInTextInBlock = mStartOffsetInTextInBlock;
+ }
+
+ private:
+ Maybe<size_t> mStartIndex;
+ Maybe<size_t> mEndIndex;
+ // Selected start and end offset in all text in a block element.
+ Maybe<uint32_t> mStartOffsetInTextInBlock;
+ Maybe<uint32_t> mEndOffsetInTextInBlock;
+ };
+ Selection mSelection;
+ };
+
+ RefPtr<dom::Document> mDocument;
+ nsCOMPtr<nsISelectionController> mSelCon;
+ RefPtr<EditorBase> mEditorBase;
+ RefPtr<FilteredContentIterator> mFilteredIter;
+ nsCOMPtr<nsIContent> mPrevTextBlock;
+ nsCOMPtr<nsIContent> mNextTextBlock;
+ OffsetEntryArray mOffsetTable;
+ RefPtr<nsRange> mExtent;
+
+ uint32_t mTxtSvcFilterType;
+ IteratorStatus mIteratorStatus;
+
+ protected:
+ virtual ~TextServicesDocument() = default;
+
+ public:
+ TextServicesDocument();
+
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS(TextServicesDocument)
+
+ /**
+ * Initializes the text services document to use a particular editor. The
+ * text services document will use the DOM document and presentation shell
+ * used by the editor.
+ *
+ * @param aEditor The editor to use.
+ */
+ nsresult InitWithEditor(nsIEditor* aEditor);
+
+ /**
+ * Sets the range/extent over which the text services document will iterate.
+ * Note that InitWithEditor() should have been called prior to calling this
+ * method. If this method is never called, the text services defaults to
+ * iterating over the entire document.
+ *
+ * @param aAbstractRange The range to use. aAbstractRange must point to a
+ * valid range object.
+ */
+ nsresult SetExtent(const dom::AbstractRange* aAbstractRange);
+
+ /**
+ * Expands the end points of the range so that it spans complete words. This
+ * call does not change any internal state of the text services document.
+ *
+ * @param aStaticRange [in/out] The range to be expanded/adjusted.
+ */
+ nsresult ExpandRangeToWordBoundaries(dom::StaticRange* aStaticRange);
+
+ /**
+ * Sets the filter type to be used while iterating over content.
+ * This will clear the current filter type if it's not either
+ * FILTERTYPE_NORMAL or FILTERTYPE_MAIL.
+ *
+ * @param aFilterType The filter type to be used while iterating over
+ * content.
+ */
+ nsresult SetFilterType(uint32_t aFilterType);
+
+ /**
+ * Returns the text in the current text block.
+ *
+ * @param aStr [OUT] This will contain the text.
+ */
+ nsresult GetCurrentTextBlock(nsAString& aStr);
+
+ /**
+ * Tells the document to point to the first text block in the document. This
+ * method does not adjust the current cursor position or selection.
+ */
+ nsresult FirstBlock();
+
+ enum class BlockSelectionStatus {
+ // There is no text block (TB) in or before the selection (S).
+ eBlockNotFound = 0,
+ // No TB in S, but found one before/after S.
+ eBlockOutside,
+ // S extends beyond the start and end of TB.
+ eBlockInside,
+ // TB contains entire S.
+ eBlockContains,
+ // S begins or ends in TB but extends outside of TB.
+ eBlockPartial,
+ };
+
+ /**
+ * Tells the document to point to the last text block that contains the
+ * current selection or caret.
+ *
+ * @param aSelectionStatus [OUT] This will contain the text block
+ * selection status.
+ * @param aSelectionOffset [OUT] This will contain the offset into the
+ * string returned by GetCurrentTextBlock() where
+ * the selection begins.
+ * @param aLength [OUT] This will contain the number of
+ * characters that are selected in the string.
+ */
+ MOZ_CAN_RUN_SCRIPT
+ nsresult LastSelectedBlock(BlockSelectionStatus* aSelStatus,
+ uint32_t* aSelOffset, uint32_t* aSelLength);
+
+ /**
+ * Tells the document to point to the text block before the current one.
+ * This method will return NS_OK, even if there is no previous block.
+ * Callers should call IsDone() to check if we have gone beyond the first
+ * text block in the document.
+ */
+ nsresult PrevBlock();
+
+ /**
+ * Tells the document to point to the text block after the current one.
+ * This method will return NS_OK, even if there is no next block. Callers
+ * should call IsDone() to check if we have gone beyond the last text block
+ * in the document.
+ */
+ nsresult NextBlock();
+
+ /**
+ * IsDone() will always set aIsDone == false unless the document contains
+ * no text, PrevBlock() was called while the document was already pointing
+ * to the first text block in the document, or NextBlock() was called while
+ * the document was already pointing to the last text block in the document.
+ *
+ * @param aIsDone [OUT] This will contain the result.
+ */
+ nsresult IsDone(bool* aIsDone);
+
+ /**
+ * SetSelection() allows the caller to set the selection based on an offset
+ * into the string returned by GetCurrentTextBlock(). A length of zero
+ * places the cursor at that offset. A positive non-zero length "n" selects
+ * n characters in the string.
+ *
+ * @param aOffset Offset into string returned by
+ * GetCurrentTextBlock().
+ * @param aLength Number of characters selected.
+ */
+ MOZ_CAN_RUN_SCRIPT nsresult SetSelection(uint32_t aOffset, uint32_t aLength);
+
+ /**
+ * Scrolls the document so that the current selection is visible.
+ */
+ nsresult ScrollSelectionIntoView();
+
+ /**
+ * Deletes the text selected by SetSelection(). Calling DeleteSelection()
+ * with nothing selected, or with a collapsed selection (cursor) does
+ * nothing and returns NS_OK.
+ */
+ MOZ_CAN_RUN_SCRIPT
+ nsresult DeleteSelection();
+
+ /**
+ * Inserts the given text at the current cursor position. If there is a
+ * selection, it will be deleted before the text is inserted.
+ */
+ MOZ_CAN_RUN_SCRIPT
+ nsresult InsertText(const nsAString& aText);
+
+ /**
+ * nsIEditActionListener method implementations.
+ */
+ NS_DECL_NSIEDITACTIONLISTENER
+
+ /**
+ * Actual edit action listeners. When you add new method here for listening
+ * to new edit action, you need to make it called by EditorBase.
+ * Additionally, you need to call it from proper method of
+ * nsIEditActionListener too because if this is created not for inline
+ * spell checker of the editor, edit actions will be notified via
+ * nsIEditActionListener (slow path, though).
+ */
+ void DidDeleteContent(const nsIContent& aChildContent);
+ void DidJoinContents(const EditorRawDOMPoint& aJoinedPoint,
+ const nsIContent& aRemovedContent);
+
+ private:
+ // TODO: We should get rid of this method since `aAbstractRange` has
+ // enough simple API to get them.
+ static nsresult GetRangeEndPoints(const dom::AbstractRange* aAbstractRange,
+ nsINode** aStartContainer,
+ uint32_t* aStartOffset,
+ nsINode** aEndContainer,
+ uint32_t* aEndOffset);
+
+ nsresult CreateFilteredContentIterator(
+ const dom::AbstractRange* aAbstractRange,
+ FilteredContentIterator** aFilteredIter);
+
+ dom::Element* GetDocumentContentRootNode() const;
+ already_AddRefed<nsRange> CreateDocumentContentRange();
+ already_AddRefed<nsRange> CreateDocumentContentRootToNodeOffsetRange(
+ nsINode* aParent, uint32_t aOffset, bool aToStart);
+ nsresult CreateDocumentContentIterator(
+ FilteredContentIterator** aFilteredIter);
+
+ nsresult AdjustContentIterator();
+
+ static nsresult FirstTextNode(FilteredContentIterator* aFilteredIter,
+ IteratorStatus* aIteratorStatus);
+ static nsresult LastTextNode(FilteredContentIterator* aFilteredIter,
+ IteratorStatus* aIteratorStatus);
+
+ static nsresult FirstTextNodeInCurrentBlock(
+ FilteredContentIterator* aFilteredIter);
+ static nsresult FirstTextNodeInPrevBlock(
+ FilteredContentIterator* aFilteredIter);
+ static nsresult FirstTextNodeInNextBlock(
+ FilteredContentIterator* aFilteredIter);
+
+ nsresult GetFirstTextNodeInPrevBlock(nsIContent** aContent);
+ nsresult GetFirstTextNodeInNextBlock(nsIContent** aContent);
+
+ static bool DidSkip(FilteredContentIterator* aFilteredIter);
+ static void ClearDidSkip(FilteredContentIterator* aFilteredIter);
+
+ static bool HasSameBlockNodeParent(dom::Text& aTextNode1,
+ dom::Text& aTextNode2);
+
+ MOZ_CAN_RUN_SCRIPT nsresult SetSelectionInternal(uint32_t aOffset,
+ uint32_t aLength,
+ bool aDoUpdate);
+ MOZ_CAN_RUN_SCRIPT nsresult GetSelection(BlockSelectionStatus* aSelStatus,
+ uint32_t* aSelOffset,
+ uint32_t* aSelLength);
+ MOZ_CAN_RUN_SCRIPT nsresult
+ GetCollapsedSelection(BlockSelectionStatus* aSelStatus, uint32_t* aSelOffset,
+ uint32_t* aSelLength);
+ nsresult GetUncollapsedSelection(BlockSelectionStatus* aSelStatus,
+ uint32_t* aSelOffset, uint32_t* aSelLength);
+};
+
+} // namespace mozilla
+
+#endif // #ifndef mozilla_TextServicesDocument_h
diff --git a/editor/spellchecker/moz.build b/editor/spellchecker/moz.build
new file mode 100644
index 0000000000..cd3d58df7e
--- /dev/null
+++ b/editor/spellchecker/moz.build
@@ -0,0 +1,34 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+MOCHITEST_MANIFESTS += ["tests/mochitest.toml"]
+
+MOCHITEST_CHROME_MANIFESTS += ["tests/chrome.toml"]
+
+XPIDL_SOURCES += [
+ "nsIInlineSpellChecker.idl",
+]
+
+XPIDL_MODULE = "txtsvc"
+
+EXPORTS.mozilla += [
+ "EditorSpellCheck.h",
+ "TextServicesDocument.h",
+]
+
+UNIFIED_SOURCES += [
+ "EditorSpellCheck.cpp",
+ "FilteredContentIterator.cpp",
+ "nsComposeTxtSrvFilter.cpp",
+ "TextServicesDocument.cpp",
+]
+
+LOCAL_INCLUDES += [
+ # For stop exposing libeditor's headers, allow to refer them directly
+ "/editor/libeditor",
+]
+
+FINAL_LIBRARY = "xul"
diff --git a/editor/spellchecker/nsComposeTxtSrvFilter.cpp b/editor/spellchecker/nsComposeTxtSrvFilter.cpp
new file mode 100644
index 0000000000..7ab6eae8cb
--- /dev/null
+++ b/editor/spellchecker/nsComposeTxtSrvFilter.cpp
@@ -0,0 +1,64 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsComposeTxtSrvFilter.h"
+#include "nsError.h" // for NS_OK
+#include "nsIContent.h" // for nsIContent
+#include "nsLiteralString.h" // for NS_LITERAL_STRING
+#include "mozilla/dom/Element.h" // for nsIContent
+
+using namespace mozilla;
+
+bool nsComposeTxtSrvFilter::Skip(nsINode* aNode) const {
+ if (NS_WARN_IF(!aNode)) {
+ return false;
+ }
+
+ // Check to see if we can skip this node
+
+ if (aNode->IsAnyOfHTMLElements(nsGkAtoms::script, nsGkAtoms::textarea,
+ nsGkAtoms::select, nsGkAtoms::style,
+ nsGkAtoms::map)) {
+ return true;
+ }
+
+ if (!mIsForMail) {
+ return false;
+ }
+
+ // For nodes that are blockquotes, we must make sure
+ // their type is "cite"
+ if (aNode->IsHTMLElement(nsGkAtoms::blockquote)) {
+ return aNode->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::type,
+ nsGkAtoms::cite, eIgnoreCase);
+ }
+
+ if (aNode->IsHTMLElement(nsGkAtoms::span)) {
+ if (aNode->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::mozquote,
+ nsGkAtoms::_true, eIgnoreCase)) {
+ return true;
+ }
+
+ return aNode->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::_class,
+ nsGkAtoms::mozsignature,
+ eCaseMatters);
+ }
+
+ if (aNode->IsHTMLElement(nsGkAtoms::table)) {
+ return aNode->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::_class,
+ u"moz-email-headers-table"_ns,
+ eCaseMatters);
+ }
+
+ return false;
+}
+
+// static
+UniquePtr<nsComposeTxtSrvFilter> nsComposeTxtSrvFilter::CreateHelper(
+ bool aIsForMail) {
+ auto filter = MakeUnique<nsComposeTxtSrvFilter>();
+ filter->Init(aIsForMail);
+ return filter;
+}
diff --git a/editor/spellchecker/nsComposeTxtSrvFilter.h b/editor/spellchecker/nsComposeTxtSrvFilter.h
new file mode 100644
index 0000000000..b2de83451b
--- /dev/null
+++ b/editor/spellchecker/nsComposeTxtSrvFilter.h
@@ -0,0 +1,45 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#ifndef nsComposeTxtSrvFilter_h__
+#define nsComposeTxtSrvFilter_h__
+
+#include "mozilla/UniquePtr.h"
+
+class nsINode;
+
+/**
+ * This class enables those using it to skip over certain nodes when
+ * traversing content.
+ *
+ * This filter is used to skip over various form control nodes and
+ * mail's cite nodes
+ */
+class nsComposeTxtSrvFilter final {
+ public:
+ static mozilla::UniquePtr<nsComposeTxtSrvFilter> CreateNormalFilter() {
+ return CreateHelper(false);
+ }
+ static mozilla::UniquePtr<nsComposeTxtSrvFilter> CreateMailFilter() {
+ return CreateHelper(true);
+ }
+
+ /**
+ * Indicates whether the content node should be skipped by the iterator
+ * @param aNode - node to skip
+ */
+ bool Skip(nsINode* aNode) const;
+
+ private:
+ // Helper - Intializer
+ void Init(bool aIsForMail) { mIsForMail = aIsForMail; }
+
+ static mozilla::UniquePtr<nsComposeTxtSrvFilter> CreateHelper(
+ bool aIsForMail);
+
+ bool mIsForMail = false;
+};
+
+#endif
diff --git a/editor/spellchecker/nsIInlineSpellChecker.idl b/editor/spellchecker/nsIInlineSpellChecker.idl
new file mode 100644
index 0000000000..d6f66a793d
--- /dev/null
+++ b/editor/spellchecker/nsIInlineSpellChecker.idl
@@ -0,0 +1,40 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsISupports.idl"
+#include "domstubs.idl"
+
+interface nsIEditor;
+interface nsIEditorSpellCheck;
+
+webidl Node;
+webidl Range;
+
+[scriptable, uuid(b7b7a77c-40c4-4196-b0b7-b0338243b3fe)]
+interface nsIInlineSpellChecker : nsISupports
+{
+ readonly attribute nsIEditorSpellCheck spellChecker;
+
+ void init(in nsIEditor aEditor);
+ void cleanup(in boolean aDestroyingFrames);
+
+ attribute boolean enableRealTimeSpell;
+
+ void spellCheckRange(in Range aSelection);
+
+ Range getMisspelledWord(in Node aNode, in unsigned long aOffset);
+ [can_run_script]
+ void replaceWord(in Node aNode,
+ in unsigned long aOffset,
+ in AString aNewword);
+ void addWordToDictionary(in AString aWord);
+ void removeWordFromDictionary(in AString aWord);
+
+ void ignoreWord(in AString aWord);
+ void ignoreWords(in Array<AString> aWordsToIgnore);
+ void updateCurrentDictionary();
+
+ readonly attribute boolean spellCheckPending;
+};
diff --git a/editor/spellchecker/tests/bug1200533_subframe.html b/editor/spellchecker/tests/bug1200533_subframe.html
new file mode 100644
index 0000000000..f095c0601f
--- /dev/null
+++ b/editor/spellchecker/tests/bug1200533_subframe.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta http-equiv="Content-Language" content="en-US">
+</head>
+<body>
+<textarea id="none">root en-US</textarea>
+<textarea id="en-GB" lang="en-GB">root en-US, but element en-GB</textarea>
+<textarea id="en-gb" lang="en-gb">root en-US, but element en-gb (lower case)</textarea>
+<textarea id="en-ZA-not-avail" lang="en-ZA">root en-US, but element en-ZA (which is not installed)</textarea>
+<textarea id="en-generic" lang="en">root en-US, but element en</textarea>
+<textarea id="en" lang="en">root en-US, but element en</textarea>
+<textarea id="ko-not-avail" lang="ko">root en-US, but element ko (which is not installed)</textarea>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/bug1204147_subframe.html b/editor/spellchecker/tests/bug1204147_subframe.html
new file mode 100644
index 0000000000..a9b1225cd9
--- /dev/null
+++ b/editor/spellchecker/tests/bug1204147_subframe.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+<head>
+</head>
+<body>
+<textarea id="en-GB" lang="en-GB">element en-GB</textarea>
+<textarea id="en-US" lang="testing-XX">element should default to en-US</textarea>
+
+<div id="trouble-maker" contenteditable>the presence of this div triggers the faulty code path</div>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/bug1204147_subframe2.html b/editor/spellchecker/tests/bug1204147_subframe2.html
new file mode 100644
index 0000000000..935777bd99
--- /dev/null
+++ b/editor/spellchecker/tests/bug1204147_subframe2.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<head>
+</head>
+<body>
+<textarea id="en-GB" lang="en-GB">element en-GB</textarea>
+<textarea id="en-US" lang="testing-XX">element should default to en-US</textarea>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/bug678842_subframe.html b/editor/spellchecker/tests/bug678842_subframe.html
new file mode 100644
index 0000000000..39d578ee41
--- /dev/null
+++ b/editor/spellchecker/tests/bug678842_subframe.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html>
+<head>
+</head>
+<body>
+<textarea id="textarea" lang="testing-XXX"></textarea>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/bug717433_subframe.html b/editor/spellchecker/tests/bug717433_subframe.html
new file mode 100644
index 0000000000..3c2927e88f
--- /dev/null
+++ b/editor/spellchecker/tests/bug717433_subframe.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html>
+<head>
+</head>
+<body>
+<textarea id="textarea" lang="en"></textarea>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/chrome.toml b/editor/spellchecker/tests/chrome.toml
new file mode 100644
index 0000000000..f5fbdfac31
--- /dev/null
+++ b/editor/spellchecker/tests/chrome.toml
@@ -0,0 +1,7 @@
+[DEFAULT]
+prefs = ["gfx.font_loader.delay=0"]
+
+skip-if = ["os == 'android'"]
+
+["test_nsIEditorSpellCheck_ReplaceWord.html"]
+support-files = ["spellcheck.js"]
diff --git a/editor/spellchecker/tests/de-DE/de_DE.aff b/editor/spellchecker/tests/de-DE/de_DE.aff
new file mode 100644
index 0000000000..5dc6896b6d
--- /dev/null
+++ b/editor/spellchecker/tests/de-DE/de_DE.aff
@@ -0,0 +1,2 @@
+# Affix file for German English dictionary
+# Fake file, nothing here.
diff --git a/editor/spellchecker/tests/de-DE/de_DE.dic b/editor/spellchecker/tests/de-DE/de_DE.dic
new file mode 100644
index 0000000000..415c216861
--- /dev/null
+++ b/editor/spellchecker/tests/de-DE/de_DE.dic
@@ -0,0 +1,6 @@
+5
+ein
+guter
+heute
+ist
+Tag
diff --git a/editor/spellchecker/tests/en-AU/en_AU.aff b/editor/spellchecker/tests/en-AU/en_AU.aff
new file mode 100644
index 0000000000..e0c467248d
--- /dev/null
+++ b/editor/spellchecker/tests/en-AU/en_AU.aff
@@ -0,0 +1,2 @@
+# Affix file for British English dictionary
+# Fake file, nothing here.
diff --git a/editor/spellchecker/tests/en-AU/en_AU.dic b/editor/spellchecker/tests/en-AU/en_AU.dic
new file mode 100644
index 0000000000..0a1be725d4
--- /dev/null
+++ b/editor/spellchecker/tests/en-AU/en_AU.dic
@@ -0,0 +1,4 @@
+3
+Mary
+Paul
+Peter
diff --git a/editor/spellchecker/tests/en-GB/en_GB.aff b/editor/spellchecker/tests/en-GB/en_GB.aff
new file mode 100644
index 0000000000..e0c467248d
--- /dev/null
+++ b/editor/spellchecker/tests/en-GB/en_GB.aff
@@ -0,0 +1,2 @@
+# Affix file for British English dictionary
+# Fake file, nothing here.
diff --git a/editor/spellchecker/tests/en-GB/en_GB.dic b/editor/spellchecker/tests/en-GB/en_GB.dic
new file mode 100644
index 0000000000..0a1be725d4
--- /dev/null
+++ b/editor/spellchecker/tests/en-GB/en_GB.dic
@@ -0,0 +1,4 @@
+3
+Mary
+Paul
+Peter
diff --git a/editor/spellchecker/tests/mochitest.toml b/editor/spellchecker/tests/mochitest.toml
new file mode 100644
index 0000000000..cd0afc4d9b
--- /dev/null
+++ b/editor/spellchecker/tests/mochitest.toml
@@ -0,0 +1,104 @@
+[DEFAULT]
+prefs = ["gfx.font_loader.delay=0"]
+
+skip-if = ["os == 'android'"]
+support-files = [
+ "en-GB/en_GB.dic",
+ "en-GB/en_GB.aff",
+ "en-AU/en_AU.dic",
+ "en-AU/en_AU.aff",
+ "de-DE/de_DE.dic",
+ "de-DE/de_DE.aff",
+ "ru-RU/ru_RU.dic",
+ "ru-RU/ru_RU.aff",
+ "spellcheck.js",
+]
+
+["test_async_UpdateCurrentDictionary.html"]
+
+["test_bug366682.html"]
+
+["test_bug432225.html"]
+
+["test_bug484181.html"]
+
+["test_bug596333.html"]
+
+["test_bug636465.html"]
+
+["test_bug678842.html"]
+skip-if = [
+ "http3",
+ "http2",
+]
+support-files = ["bug678842_subframe.html"]
+
+["test_bug697981.html"]
+
+["test_bug717433.html"]
+skip-if = [
+ "http3",
+ "http2",
+]
+support-files = ["bug717433_subframe.html"]
+
+["test_bug1100966.html"]
+
+["test_bug1154791.html"]
+
+["test_bug1200533.html"]
+skip-if = [
+ "http3",
+ "http2",
+]
+support-files = ["bug1200533_subframe.html"]
+
+["test_bug1204147.html"]
+skip-if = [
+ "http3",
+ "http2",
+]
+support-files = [
+ "bug1204147_subframe.html",
+ "bug1204147_subframe2.html",
+]
+
+["test_bug1205983.html"]
+
+["test_bug1209414.html"]
+
+["test_bug1219928.html"]
+skip-if = ["true"]
+
+["test_bug1365383.html"]
+
+["test_bug1368544.html"]
+
+["test_bug1402822.html"]
+
+["test_bug1418629.html"]
+
+["test_bug1497480.html"]
+
+["test_bug1602526.html"]
+
+["test_bug1761273.html"]
+
+["test_bug1773802.html"]
+
+["test_bug1837268.html"]
+
+["test_multiple_content_languages.html"]
+skip-if = [
+ "http3",
+ "http2",
+]
+support-files = ["multiple_content_languages_subframe.html"]
+
+["test_spellcheck_after_edit.html"]
+
+["test_spellcheck_after_pressing_navigation_key.html"]
+
+["test_spellcheck_selection.html"]
+
+["test_suggest.html"]
diff --git a/editor/spellchecker/tests/multiple_content_languages_subframe.html b/editor/spellchecker/tests/multiple_content_languages_subframe.html
new file mode 100644
index 0000000000..da6a4ed664
--- /dev/null
+++ b/editor/spellchecker/tests/multiple_content_languages_subframe.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta http-equiv="Content-Language" content="en-US, en-GB, ko, en-CA">
+</head>
+<body>
+<textarea id="none">root en-US and en-GB</textarea>
+<textarea id="en-GB" lang="en-GB">root multiple, but element only en-GB</textarea>
+<textarea id="en-ZA-not-avail" lang="en-ZA">root multiple en, but element en-ZA (which is not installed)</textarea>
+<textarea id="en" lang="en">root multiple en, but element en</textarea>
+<textarea id="ko-not-avail" lang="ko">root multiple en, but element ko (which is not installed)</textarea>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/ru-RU/ru_RU.aff b/editor/spellchecker/tests/ru-RU/ru_RU.aff
new file mode 100644
index 0000000000..c6cf721484
--- /dev/null
+++ b/editor/spellchecker/tests/ru-RU/ru_RU.aff
@@ -0,0 +1 @@
+SET KOI8-R
diff --git a/editor/spellchecker/tests/ru-RU/ru_RU.dic b/editor/spellchecker/tests/ru-RU/ru_RU.dic
new file mode 100644
index 0000000000..dbfe3a9f23
--- /dev/null
+++ b/editor/spellchecker/tests/ru-RU/ru_RU.dic
@@ -0,0 +1,2 @@
+1
+ÐÒÁ×ÉÌØÎÙÊ
diff --git a/editor/spellchecker/tests/spellcheck.js b/editor/spellchecker/tests/spellcheck.js
new file mode 100644
index 0000000000..b6a1586628
--- /dev/null
+++ b/editor/spellchecker/tests/spellcheck.js
@@ -0,0 +1,36 @@
+function isSpellingCheckOk(aEditor, aMisspelledWords, aTodo = false) {
+ var selcon = aEditor.selectionController;
+ var sel = selcon.getSelection(selcon.SELECTION_SPELLCHECK);
+ var numWords = sel.rangeCount;
+
+ if (aTodo) {
+ todo_is(
+ numWords,
+ aMisspelledWords.length,
+ "Correct number of misspellings and words."
+ );
+ } else {
+ is(
+ numWords,
+ aMisspelledWords.length,
+ "Correct number of misspellings and words."
+ );
+ }
+
+ if (numWords !== aMisspelledWords.length) {
+ return false;
+ }
+
+ for (var i = 0; i < numWords; ++i) {
+ var word = String(sel.getRangeAt(i));
+ if (aTodo) {
+ todo_is(word, aMisspelledWords[i], "Misspelling is what we think it is.");
+ } else {
+ is(word, aMisspelledWords[i], "Misspelling is what we think it is.");
+ }
+ if (word !== aMisspelledWords[i]) {
+ return false;
+ }
+ }
+ return true;
+}
diff --git a/editor/spellchecker/tests/test_async_UpdateCurrentDictionary.html b/editor/spellchecker/tests/test_async_UpdateCurrentDictionary.html
new file mode 100644
index 0000000000..d586eef721
--- /dev/null
+++ b/editor/spellchecker/tests/test_async_UpdateCurrentDictionary.html
@@ -0,0 +1,60 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=856270
+-->
+<head>
+ <title>Test for Bug 856270 - Async UpdateCurrentDictionary</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=856270">Mozilla Bug 856270</a>
+<p id="display"></p>
+<div id="content">
+<textarea id="editor" spellcheck="true"></textarea>
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+SimpleTest.waitForExplicitFinish();
+addLoadEvent(start);
+
+function start() {
+ var textarea = document.getElementById("editor");
+ textarea.focus();
+
+ const { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ )
+ onSpellCheck(textarea, function() {
+ var isc = SpecialPowers.wrap(textarea).editor.getInlineSpellChecker(false);
+ ok(isc, "Inline spell checker should exist after focus and spell check");
+ var sc = isc.spellChecker;
+
+ sc.setCurrentDictionaries(["testing-XX"]).then(() => {
+ is(true, false, "Setting a non-existent dictionary should fail");
+ }, () => {
+ let currentDictionaries = sc.getCurrentDictionaries();
+
+ is(currentDictionaries.length, 0, "expected no dictionaries");
+ // First, set the lang attribute on the textarea, call Update, and make
+ // sure the spell checker's language was updated appropriately.
+ var lang = "en-US";
+ textarea.setAttribute("lang", lang);
+ sc.UpdateCurrentDictionary(function() {
+ currentDictionaries = sc.getCurrentDictionaries();
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ is(sc.getCurrentDictionaries()[0], lang,
+ "UpdateCurrentDictionary should set the current dictionary.");
+ sc.setCurrentDictionaries([]).then(() => {
+ SimpleTest.finish();
+ });
+ });
+ });
+ });
+}
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1100966.html b/editor/spellchecker/tests/test_bug1100966.html
new file mode 100644
index 0000000000..15f1e03a3b
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1100966.html
@@ -0,0 +1,74 @@
+<!DOCTYPE>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1100966
+-->
+<head>
+ <title>Test for Bug 1100966</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+</head>
+<body>
+<div id="display">
+</div>
+<div id="content" contenteditable>
+=====<br>
+correct<br>
+fivee sixx<br>
+====
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="application/javascript">
+
+let { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+/** Test for Bug 1100966 **/
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(function() {
+ var div = document.getElementById("content");
+ div.focus();
+ synthesizeMouseAtCenter(div, {});
+
+ getSpellChecker().UpdateCurrentDictionary(() => {
+ sendString(" ");
+ SimpleTest.executeSoon(function() {
+ sendString("a");
+ SimpleTest.executeSoon(function() {
+ synthesizeKey("KEY_Backspace");
+
+ maybeOnSpellCheck(div, function() {
+ var sel = getSpellCheckSelection();
+ is(sel.rangeCount, 2, "We should have two misspelled words");
+ is(String(sel.getRangeAt(0)), "fivee", "Correct misspelled word");
+ is(String(sel.getRangeAt(1)), "sixx", "Correct misspelled word");
+
+ SimpleTest.finish();
+ });
+ });
+ });
+ });
+});
+
+function getEditor() {
+ var editingSession = SpecialPowers.wrap(window).docShell.editingSession;
+ return editingSession.getEditorForWindow(window);
+}
+
+function getSpellChecker() {
+ return getEditor().getInlineSpellChecker(false).spellChecker;
+}
+
+function getSpellCheckSelection() {
+ var selcon = getEditor().selectionController;
+ return selcon.getSelection(selcon.SELECTION_SPELLCHECK);
+}
+
+</script>
+</body>
+
+</html>
diff --git a/editor/spellchecker/tests/test_bug1154791.html b/editor/spellchecker/tests/test_bug1154791.html
new file mode 100644
index 0000000000..1552c7eff8
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1154791.html
@@ -0,0 +1,74 @@
+<!DOCTYPE>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1154791
+-->
+<head>
+ <title>Test for Bug 1154791</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+</head>
+<body>
+<div id="display">
+</div>
+
+<div id="content" contenteditable>
+<tt>thiss onee is stilll a</tt>
+</div>
+
+<pre id="test">
+</pre>
+
+<script class="testbody" type="application/javascript">
+
+let { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+/** Test for Bug 1154791 **/
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(function() {
+ var div = document.getElementById("content");
+ div.focus();
+ getSpellChecker().UpdateCurrentDictionary(() => {
+ synthesizeMouseAtCenter(div, {});
+ synthesizeKey("KEY_ArrowLeft");
+ synthesizeKey("KEY_ArrowLeft");
+
+ SimpleTest.executeSoon(function() {
+ synthesizeKey("KEY_Backspace");
+ SimpleTest.executeSoon(function() {
+ sendString(" ");
+
+ maybeOnSpellCheck(div, function() {
+ var sel = getSpellCheckSelection();
+ is(sel.rangeCount, 2, "We should have two misspelled words");
+ is(String(sel.getRangeAt(0)), "thiss", "Correct misspelled word");
+ is(String(sel.getRangeAt(1)), "onee", "Correct misspelled word");
+
+ SimpleTest.finish();
+ });
+ });
+ });
+ });
+});
+
+function getEditor() {
+ var editingSession = SpecialPowers.wrap(window).docShell.editingSession;
+ return editingSession.getEditorForWindow(window);
+}
+
+function getSpellChecker() {
+ return getEditor().getInlineSpellChecker(false).spellChecker;
+}
+
+function getSpellCheckSelection() {
+ var selcon = getEditor().selectionController;
+ return selcon.getSelection(selcon.SELECTION_SPELLCHECK);
+}
+
+</script>
+</body>
+
+</html>
diff --git a/editor/spellchecker/tests/test_bug1200533.html b/editor/spellchecker/tests/test_bug1200533.html
new file mode 100644
index 0000000000..0f75825013
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1200533.html
@@ -0,0 +1,163 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1200533
+-->
+<head>
+ <title>Test for Bug 1200533</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1200533">Mozilla Bug 1200533</a>
+<p id="display"></p>
+<iframe id="content"></iframe>
+
+</div>
+<pre id="test">
+<script class="testbody" ttype="application/javascript">
+
+/** Test for Bug 1200533 **/
+/** Visit the elements defined above and check the dictionary we got **/
+SimpleTest.waitForExplicitFinish();
+var content = document.getElementById("content");
+
+var tests = [
+ // text area, value of spellchecker.dictionary, result.
+ // Result: Document language.
+ [ "none", "", "en-US" ],
+ // Result: Element language.
+ [ "en-GB", "", "en-GB" ],
+ [ "en-gb", "", "en-GB" ],
+ // Result: Random en-* or en-US (if application locale is en-US).
+ [ "en-ZA-not-avail", "", "*" ],
+ [ "en-generic", "", "*" ],
+ [ "en", "", "*" ],
+ // Result: Locale.
+ [ "ko-not-avail", "", "en-US" ],
+
+ // Result: Preference value in all cases.
+ [ "en-ZA-not-avail", "en-AU", "en-AU" ],
+ [ "en-generic", "en-AU", "en-AU" ],
+ [ "ko-not-avail", "en-AU", "en-AU" ],
+
+ // Result: Random en-*.
+ [ "en-ZA-not-avail", "de-DE", "*" ],
+ [ "en-generic", "de-DE", "*" ],
+ // Result: Preference value.
+ [ "ko-not-avail", "de-DE", "de-DE" ],
+ ];
+
+var loadCount = 0;
+var retrying = false;
+var script;
+
+var loadListener = async function(evt) {
+ if (loadCount == 0) {
+ script = SpecialPowers.loadChromeScript(function() {
+ /* eslint-env mozilla/chrome-script */
+ // eslint-disable-next-line mozilla/use-services
+ var dir = Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties)
+ .get("CurWorkD", Ci.nsIFile);
+ dir.append("tests");
+ dir.append("editor");
+ dir.append("spellchecker");
+ dir.append("tests");
+
+ var hunspell = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine);
+
+ // Install en-GB, en-AU and de-DE dictionaries.
+ var en_GB = dir.clone();
+ var en_AU = dir.clone();
+ var de_DE = dir.clone();
+ en_GB.append("en-GB");
+ en_AU.append("en-AU");
+ de_DE.append("de-DE");
+ hunspell.addDirectory(en_GB);
+ hunspell.addDirectory(en_AU);
+ hunspell.addDirectory(de_DE);
+
+ addMessageListener("check-existence",
+ () => [en_GB.exists(), en_AU.exists(),
+ de_DE.exists()]);
+ addMessageListener("destroy", () => {
+ hunspell.removeDirectory(en_GB);
+ hunspell.removeDirectory(en_AU);
+ hunspell.removeDirectory(de_DE);
+ });
+ });
+ var existenceChecks = await script.sendQuery("check-existence");
+ is(existenceChecks[0], true, "true expected (en-GB directory should exist)");
+ is(existenceChecks[1], true, "true expected (en-AU directory should exist)");
+ is(existenceChecks[2], true, "true expected (de-DE directory should exist)");
+ }
+
+ SpecialPowers.pushPrefEnv({set: [["spellchecker.dictionary", tests[loadCount][1]]]},
+ function() { continueTest(evt); });
+};
+
+function continueTest(evt) {
+ var doc = evt.target.contentDocument;
+ var elem = doc.getElementById(tests[loadCount][0]);
+ var editor = SpecialPowers.wrap(elem).editor;
+ editor.setSpellcheckUserOverride(true);
+ var inlineSpellChecker = editor.getInlineSpellChecker(true);
+ const is_en_US = SpecialPowers.Services.locale.appLocaleAsBCP47 == "en-US";
+
+ const { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ onSpellCheck(elem, async function() {
+ var spellchecker = inlineSpellChecker.spellChecker;
+ let currentDictionaries;
+ try {
+ currentDictionaries = spellchecker.getCurrentDictionaries();
+ } catch (e) {}
+
+ if (!currentDictionaries && !retrying) {
+ // It's possible for an asynchronous font-list update to cause a reflow
+ // that disrupts the async spell-check and results in not getting a
+ // current dictionary here; if that happens, we retry the same testcase
+ // by reloading the iframe without bumping loadCount.
+ info(`No current dictionary: retrying testcase ${loadCount}`);
+ retrying = true;
+ } else {
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ let dict = currentDictionaries[0];
+ if (tests[loadCount][2] != "*") {
+ is(dict, tests[loadCount][2], "expected " + tests[loadCount][2]);
+ } else if (is_en_US && tests[loadCount][0].startsWith("en")) {
+ // Current application locale is en-US and content lang is en or
+ // en-unknown, so we should use en-US dictionary as default.
+ is(dict, "en-US", "expected en-US that is application locale");
+ } else {
+ var gotEn = (dict == "en-GB" || dict == "en-AU" || dict == "en-US");
+ is(gotEn, true, "expected en-AU or en-GB or en-US");
+ }
+
+ loadCount++;
+ retrying = false;
+ }
+
+ if (loadCount < tests.length) {
+ // Load the iframe again.
+ content.src = "http://mochi.test:8888/tests/editor/spellchecker/tests/bug1200533_subframe.html?firstload=false";
+ } else {
+ // Remove the fake dictionaries again, since it's otherwise picked up by later tests.
+ await script.sendQuery("destroy");
+
+ SimpleTest.finish();
+ }
+ });
+}
+
+content.addEventListener("load", loadListener);
+
+content.src = "http://mochi.test:8888/tests/editor/spellchecker/tests/bug1200533_subframe.html?firstload=true";
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1204147.html b/editor/spellchecker/tests/test_bug1204147.html
new file mode 100644
index 0000000000..39935c0d5c
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1204147.html
@@ -0,0 +1,115 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1204147
+-->
+<head>
+ <title>Test for Bug 1204147</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1204147">Mozilla Bug 1204147</a>
+<p id="display"></p>
+<iframe id="content"></iframe>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Bug 1204147 **/
+SimpleTest.waitForExplicitFinish();
+var content = document.getElementById("content");
+// Load a subframe containing an editor with using "en-GB". At first
+// load, it will set the dictionary to "en-GB". The bug was that a content preference
+// was also created. At second load, we check the dictionary for another element,
+// one that should use "en-US". With the bug corrected, we get "en-US", before
+// we got "en-GB" from the content preference.
+
+var firstLoad = true;
+var script;
+
+var loadListener = async function(evt) {
+ if (firstLoad) {
+ script = SpecialPowers.loadChromeScript(function() {
+ /* eslint-env mozilla/chrome-script */
+ // eslint-disable-next-line mozilla/use-services
+ var dir = Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties)
+ .get("CurWorkD", Ci.nsIFile);
+ dir.append("tests");
+ dir.append("editor");
+ dir.append("spellchecker");
+ dir.append("tests");
+
+ var hunspell = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine);
+
+ // Install en-GB dictionary.
+ let en_GB = dir.clone();
+ en_GB.append("en-GB");
+ hunspell.addDirectory(en_GB);
+
+ addMessageListener("en_GB-exists", () => en_GB.exists());
+ addMessageListener("destroy", () => hunspell.removeDirectory(en_GB));
+ });
+ is(await script.sendQuery("en_GB-exists"), true,
+ "true expected (en-GB directory should exist)");
+ }
+
+ var doc = evt.target.contentDocument;
+ var elem;
+ if (firstLoad) {
+ elem = doc.getElementById("en-GB");
+ } else {
+ elem = doc.getElementById("en-US");
+ }
+
+ var editor = SpecialPowers.wrap(elem).editor;
+ editor.setSpellcheckUserOverride(true);
+ var inlineSpellChecker = editor.getInlineSpellChecker(true);
+
+ const { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ onSpellCheck(elem, async function() {
+ let spellchecker = inlineSpellChecker.spellChecker;
+ let currentDictionaries = spellchecker.getCurrentDictionaries();
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ let currentDictionary = currentDictionaries[0];
+ if (firstLoad) {
+ firstLoad = false;
+
+ // First time around, the element's language should be used.
+ is(currentDictionary, "en-GB", "unexpected lang " + currentDictionary + " instead of en-GB");
+
+ // Note that on second load, we load a different page, which does NOT have the trouble-causing
+ // contenteditable in it. Sadly, loading the same page with the trouble-maker in it
+ // doesn't allow the retrieval of the spell check dictionary used for the element,
+ // because the trouble-maker causes the 'global' spell check dictionary to be set to "en-GB"
+ // (since it picks the first one from the list) before we have the chance to retrieve
+ // the dictionary for the element (which happens asynchonously after the spell check has completed).
+ content.src = "http://mochi.test:8888/tests/editor/spellchecker/tests/bug1204147_subframe2.html?firstload=false";
+ } else {
+ // Second time around, the element should default to en-US.
+ // Without the fix, the first run sets the content preference to en-GB for the whole site.
+ is(currentDictionary, "en-US", "unexpected lang " + currentDictionary + " instead of en-US");
+ content.removeEventListener("load", loadListener);
+
+ // Remove the fake en-GB dictionary again, since it's otherwise picked up by later tests.
+ await script.sendQuery("destroy");
+
+ // Reset the preference, so the last value we set doesn't collide with the next test.
+ SimpleTest.finish();
+ }
+ });
+};
+
+content.addEventListener("load", loadListener);
+
+content.src = "http://mochi.test:8888/tests/editor/spellchecker/tests/bug1204147_subframe.html?firstload=true";
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1205983.html b/editor/spellchecker/tests/test_bug1205983.html
new file mode 100644
index 0000000000..052f46d312
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1205983.html
@@ -0,0 +1,134 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1205983
+-->
+<head>
+ <title>Test for Bug 1205983</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1205983">Mozilla Bug 1205983</a>
+<p id="display"></p>
+</div>
+
+<div contenteditable id="de-DE" lang="de-DE" onfocus="deFocus()">German heute ist ein guter Tag</div>
+<textarea id="en-US" lang="en-US" onfocus="enFocus()">Nogoodword today is a nice day</textarea>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+function getMisspelledWords(editor) {
+ return editor.selectionController.getSelection(SpecialPowers.Ci.nsISelectionController.SELECTION_SPELLCHECK).toString();
+}
+
+var elem_de;
+var editor_de;
+var selcon_de;
+var script;
+
+var { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+/** Test for Bug 1205983 **/
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(async function() {
+ script = SpecialPowers.loadChromeScript(function() {
+ /* eslint-env mozilla/chrome-script */
+ // eslint-disable-next-line mozilla/use-services
+ var dir = Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties)
+ .get("CurWorkD", Ci.nsIFile);
+ dir.append("tests");
+ dir.append("editor");
+ dir.append("spellchecker");
+ dir.append("tests");
+
+ var hunspell = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine);
+
+ // Install de-DE dictionary.
+ var de_DE = dir.clone();
+ de_DE.append("de-DE");
+ hunspell.addDirectory(de_DE);
+
+ addMessageListener("de_DE-exists", () => de_DE.exists());
+ addMessageListener("destroy", () => hunspell.removeDirectory(de_DE));
+ });
+ is(await script.sendQuery("de_DE-exists"), true,
+ "true expected (de_DE directory should exist)");
+
+ document.getElementById("de-DE").focus();
+});
+
+function deFocus() {
+ elem_de = document.getElementById("de-DE");
+
+ maybeOnSpellCheck(elem_de, function() {
+ var editingSession = SpecialPowers.wrap(window).docShell.editingSession;
+ editor_de = editingSession.getEditorForWindow(window);
+ selcon_de = editor_de.selectionController;
+ var sel = selcon_de.getSelection(selcon_de.SELECTION_SPELLCHECK);
+
+ // Check that we spelled in German, so there is only one misspelled word.
+ is(sel.toString(), "German", "one misspelled word expected: German");
+
+ // Now focus the textarea, which requires English spelling.
+ document.getElementById("en-US").focus();
+ });
+}
+
+function enFocus() {
+ var elem_en = document.getElementById("en-US");
+ var editor_en =
+ SpecialPowers.wrap(elem_en)
+ .editor;
+ editor_en.setSpellcheckUserOverride(true);
+ var inlineSpellChecker = editor_en.getInlineSpellChecker(true);
+
+ maybeOnSpellCheck(elem_en, async function() {
+ var spellchecker = inlineSpellChecker.spellChecker;
+ let currentDictionaries = spellchecker.getCurrentDictionaries();
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ let currentDictionary = currentDictionaries[0];
+
+ // Check that the English dictionary is loaded and that the spell check has worked.
+ is(currentDictionary, "en-US", "expected en-US");
+ is(getMisspelledWords(editor_en), "Nogoodword", "one misspelled word expected: Nogoodword");
+
+ // So far all was boring. The important thing is whether the spell check result
+ // in the de-DE editor is still the same. After losing focus, no spell check
+ // updates should take place there.
+ var sel = selcon_de.getSelection(selcon_de.SELECTION_SPELLCHECK);
+ is(sel.toString(), "German", "one misspelled word expected: German");
+
+ // Remove the fake de_DE dictionary again.
+ await script.sendQuery("destroy");
+
+ // Focus again, so the spelling gets updated, but before we need to kill the focus handler.
+ elem_de.onfocus = null;
+ elem_de.blur();
+ elem_de.focus();
+
+ // After removal, the de_DE editor should refresh the spelling with en-US.
+ maybeOnSpellCheck(elem_de, function() {
+ var endSel = selcon_de.getSelection(selcon_de.SELECTION_SPELLCHECK);
+ // eslint-disable-next-line no-useless-concat
+ is(endSel.toString(), "heute" + "ist" + "ein" + "guter",
+ "some misspelled words expected: heute ist ein guter");
+
+ // If we don't reset this, we cause massive leaks.
+ selcon_de = null;
+ editor_de = null;
+
+ SimpleTest.finish();
+ });
+ });
+}
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1209414.html b/editor/spellchecker/tests/test_bug1209414.html
new file mode 100644
index 0000000000..1f266758c2
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1209414.html
@@ -0,0 +1,144 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1209414
+-->
+<head>
+ <title>Test for Bug 1209414</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1209414">Mozilla Bug 1209414</a>
+<p id="display"></p>
+</div>
+
+<textarea id="de-DE" lang="de-DE">heute ist ein guter Tag - today is a good day</textarea>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+const Ci = SpecialPowers.Ci;
+
+function getMisspelledWords(editor) {
+ return editor.selectionController.getSelection(Ci.nsISelectionController.SELECTION_SPELLCHECK).toString();
+}
+
+var elem_de;
+var editor_de;
+var script;
+
+/** Test for Bug 1209414 **/
+/*
+ * All we want to do in this test is change the spelling using a right-click and selection from the menu.
+ * This is necessary since all the other tests use setCurrentDictionaries() which doesn't reflect
+ * user behaviour.
+ */
+
+let { maybeOnSpellCheck, onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(async function() {
+ script = SpecialPowers.loadChromeScript(function() {
+ /* eslint-env mozilla/chrome-script */
+ var chromeWin = actorParent.rootFrameLoader
+ .ownerElement.ownerGlobal.browsingContext.topChromeWindow;
+ var contextMenu = chromeWin.document.getElementById("contentAreaContextMenu");
+ contextMenu.addEventListener("popupshown",
+ () => sendAsyncMessage("popupshown"));
+
+ // eslint-disable-next-line mozilla/use-services
+ var dir = Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties)
+ .get("CurWorkD", Ci.nsIFile);
+ dir.append("tests");
+ dir.append("editor");
+ dir.append("spellchecker");
+ dir.append("tests");
+
+ var hunspell = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine);
+
+ // Install de-DE dictionary.
+ let de_DE = dir.clone();
+ de_DE.append("de-DE");
+ hunspell.addDirectory(de_DE);
+
+ addMessageListener("hidepopup", function() {
+ var state = contextMenu.state;
+
+ // Select Language from the menu. Take a look at
+ // toolkit/modules/InlineSpellChecker.sys.mjs to see how the menu works.
+ contextMenu.ownerDocument.getElementById("spell-check-dictionary-en-US")
+ .doCommand();
+ contextMenu.hidePopup();
+ return state;
+ });
+ addMessageListener("destroy", () => hunspell.removeDirectory(de_DE));
+ addMessageListener("contextMenu-not-null", () => contextMenu != null);
+ addMessageListener("de_DE-exists", () => de_DE.exists());
+ });
+ is(await script.sendQuery("contextMenu-not-null"), true,
+ "Got context menu XUL");
+ is(await script.sendQuery("de_DE-exists"), true,
+ "true expected (de_DE directory should exist)");
+ script.addMessageListener("popupshown", handlePopup);
+
+ elem_de = document.getElementById("de-DE");
+ editor_de = SpecialPowers.wrap(elem_de).editor;
+ editor_de.setSpellcheckUserOverride(true);
+
+ maybeOnSpellCheck(elem_de, function() {
+ var inlineSpellChecker = editor_de.getInlineSpellChecker(true);
+ var spellchecker = inlineSpellChecker.spellChecker;
+ let currentDictionaries = spellchecker.getCurrentDictionaries();
+
+ // Check that the German dictionary is loaded and that the spell check has worked.
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ is(currentDictionaries[0], "de-DE", "expected de-DE");
+ // eslint-disable-next-line no-useless-concat
+ is(getMisspelledWords(editor_de), "today" + "is" + "a" + "good" + "day", "some misspelled words expected: today is a good day");
+
+ // Focus again, just to be sure that the context-click won't trigger another spell check.
+ elem_de.focus();
+
+ // Make sure all spell checking action is done before right-click to select the en-US dictionary.
+ maybeOnSpellCheck(elem_de, function() {
+ synthesizeMouse(elem_de, 2, 2, { type: "contextmenu", button: 2 }, window);
+ });
+ });
+});
+
+async function handlePopup() {
+ var state = await script.sendQuery("hidepopup");
+ is(state, "open", "checking if popup is open");
+
+ onSpellCheck(elem_de, async function() {
+ var inlineSpellChecker = editor_de.getInlineSpellChecker(true);
+ var spellchecker = inlineSpellChecker.spellChecker;
+ let currentDictionaries = spellchecker.getCurrentDictionaries();
+
+ // Check that the English dictionary is loaded and that the spell check has worked.
+ is(currentDictionaries.length, 2, "expected two dictionaries");
+ let dictionaryArray = Array.from(currentDictionaries);
+ ok(dictionaryArray.includes("de-DE"), "expected de-DE");
+ ok(dictionaryArray.includes("en-US"), "expected en-US");
+ is(getMisspelledWords(editor_de), "", "No misspelled words expected");
+
+ // Remove the fake de_DE dictionary again.
+ await script.sendQuery("destroy");
+
+ // This will clear the content preferences and reset "spellchecker.dictionary".
+ spellchecker.setCurrentDictionaries([]).then(() => {
+ SimpleTest.finish();
+ });
+ });
+}
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1219928.html b/editor/spellchecker/tests/test_bug1219928.html
new file mode 100644
index 0000000000..0636b981df
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1219928.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1219928
+-->
+<head>
+ <title>Test for Bug 1219928</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1219928">Mozilla Bug 1219928</a>
+<p id="display"></p>
+
+<div contenteditable id="en-US" lang="en-US">
+<p>And here a missspelled word</p>
+<style>
+<!-- and here another onnee in a style comment -->
+</style>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Bug 1219928 **/
+/* Very simple test to check that <style> blocks are skipped in the spell check */
+
+var spellchecker;
+
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(function() {
+ var { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+
+ var elem = document.getElementById("en-US");
+ elem.focus();
+
+ maybeOnSpellCheck(elem, function() {
+ var editingSession = SpecialPowers.wrap(window).docShell.editingSession;
+ var editor = editingSession.getEditorForWindow(window);
+ var selcon = editor.selectionController;
+ var sel = selcon.getSelection(selcon.SELECTION_SPELLCHECK);
+
+ is(sel.toString(), "missspelled", "one misspelled word expected: missspelled");
+
+ spellchecker = SpecialPowers.Cu.createSpellChecker();
+ spellchecker.setFilterType(spellchecker.FILTERTYPE_NORMAL);
+ spellchecker.InitSpellChecker(editor, false, spellCheckStarted);
+ });
+});
+
+function spellCheckStarted() {
+ var misspelledWord = spellchecker.GetNextMisspelledWord();
+ is(misspelledWord, "missspelled", "first misspelled word expected: missspelled");
+
+ // Without the fix, the next misspelled word was 'onnee', so we check that we don't get it.
+ misspelledWord = spellchecker.GetNextMisspelledWord();
+ isnot(misspelledWord, "onnee", "second misspelled word should not be: onnee");
+
+ spellchecker = "";
+
+ SimpleTest.finish();
+}
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1365383.html b/editor/spellchecker/tests/test_bug1365383.html
new file mode 100644
index 0000000000..a4b71d15bb
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1365383.html
@@ -0,0 +1,46 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1365383
+-->
+<head>
+ <title>Test for Bug 1365383</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1365383">Mozilla Bug 1365383</a>
+<p id="display"></p>
+<div id="content">
+<textarea id="editor" spellcheck="true"></textarea>
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(() => {
+ let textarea = document.getElementById("editor");
+ let editor = SpecialPowers.wrap(textarea).editor;
+
+ let spellChecker = SpecialPowers.Cu.createSpellChecker();
+
+ // Callback parameter isn't set
+ spellChecker.InitSpellChecker(editor, false);
+
+ textarea.focus();
+
+ const { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ onSpellCheck(textarea, () => {
+ // Callback parameter isn't set
+ spellChecker.UpdateCurrentDictionary();
+
+ var canSpellCheck = spellChecker.canSpellCheck();
+ ok(canSpellCheck, "spellCheck is enabled");
+ SimpleTest.finish();
+ });
+});
+</script>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1368544.html b/editor/spellchecker/tests/test_bug1368544.html
new file mode 100644
index 0000000000..577e90635f
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1368544.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi=id=1368544
+-->
+<html>
+<head>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1368544">Mozilla Bug 1368544</a>
+<div id="display"></div>
+<textarea id=textarea></textarea>
+<pre id="test">
+</pre>
+
+<script class="testbody">
+function hasEmptyTextNode(div) {
+ return div.firstChild.nodeType === Node.TEXT_NODE && div.firstChild.length === 0;
+}
+
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(() => {
+ let textarea = document.getElementById("textarea");
+ let editor = SpecialPowers.wrap(textarea).editor;
+
+ let spellChecker = SpecialPowers.Cu.createSpellChecker();
+ spellChecker.InitSpellChecker(editor, false);
+
+ textarea.focus();
+
+ const { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ onSpellCheck(textarea, () => {
+ spellChecker.UpdateCurrentDictionary(() => {
+ textarea.value = "ABC";
+ ok(editor.rootElement.hasChildNodes(),
+ "editor of textarea has child nodes");
+ sendString("D");
+ is(textarea.value, "ABCD", "D is last character");
+ ok(editor.rootElement.hasChildNodes(),
+ "editor of textarea has child nodes");
+ textarea.value = "";
+ ok(editor.rootElement.hasChildNodes(),
+ "editor of textarea has child node even if value is empty");
+
+ sendString("AAA");
+ synthesizeKey("KEY_Backspace", {repeat: 3});
+ is(textarea.value, "", "value is empty");
+ ok(editor.rootElement.hasChildNodes(),
+ "editor of textarea has child node even if value is empty");
+
+ textarea.value = "ABC";
+ SpecialPowers.wrap(textarea).setUserInput("");
+ is(textarea.value, "",
+ "textarea should become empty when setUserInput() is called with empty string");
+ ok(hasEmptyTextNode(editor.rootElement),
+ "editor of textarea should only have an empty text node when user input emulation set the value to empty");
+ todo(editor.rootElement.childNodes.length === 1, "editor of textarea should only have a single child");
+ if (editor.rootElement.childNodes.length > 1) {
+ is(editor.rootElement.childNodes.length, 2, "There should be only one additional <br> node");
+ is(editor.rootElement.lastChild.tagName.toLowerCase(), "br", "The node should be a <br> element node");
+ ok(!SpecialPowers.wrap(editor.rootElement.lastChild).isPaddingForEmptyEditor,
+ "The <br> should not be a padding <br> element");
+ }
+ textarea.value = "ABC";
+ synthesizeKey("KEY_Enter", {repeat: 2});
+ textarea.value = "";
+ ok(editor.rootElement.hasChildNodes(),
+ "editor of textarea has child node even if value is empty");
+
+ sendString("AAA");
+ is(textarea.value, "AAA", "value is AAA");
+ textarea.addEventListener("keyup", (e) => {
+ if (e.key == "Enter") {
+ textarea.value = "";
+ ok(editor.rootElement.hasChildNodes(),
+ "editor of textarea has child node even if value is empty");
+ SimpleTest.finish();
+ }
+ });
+
+ synthesizeKey("KEY_Enter");
+ });
+ });
+});
+</script>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1402822.html b/editor/spellchecker/tests/test_bug1402822.html
new file mode 100644
index 0000000000..505bca6dbb
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1402822.html
@@ -0,0 +1,114 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1402822
+-->
+<head>
+ <title>Test for Bug 1402822</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1402822">Mozilla Bug 1402822</a>
+<p id="display"></p>
+</div>
+
+<textarea id="editor">heute ist ein guter Tag - today is a good day</textarea>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+const Ci = SpecialPowers.Ci;
+
+let { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+function getMisspelledWords(editor) {
+ return editor.selectionController.getSelection(Ci.nsISelectionController.SELECTION_SPELLCHECK).toString();
+}
+
+/** Test for Bug 1402822 **/
+SimpleTest.waitForExplicitFinish();
+addLoadEvent(start);
+async function start() {
+ let script = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ // eslint-disable-next-line mozilla/use-services
+ let dir = Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties)
+ .get("CurWorkD", Ci.nsIFile);
+ dir.append("tests");
+ dir.append("editor");
+ dir.append("spellchecker");
+ dir.append("tests");
+
+ let hunspell = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine);
+
+ // Install de-DE dictionary.
+ let de_DE = dir.clone();
+ de_DE.append("de-DE");
+ hunspell.addDirectory(de_DE);
+
+ addMessageListener("destroy", () => hunspell.removeDirectory(de_DE));
+ addMessageListener("de_DE-exists", () => de_DE.exists());
+ });
+ is(await script.sendQuery("de_DE-exists"), true,
+ "true expected (de_DE directory should exist)");
+
+ let textarea = document.getElementById("editor");
+ let editor = SpecialPowers.wrap(textarea).editor;
+ textarea.focus();
+
+ maybeOnSpellCheck(textarea, () => {
+ let isc = SpecialPowers.wrap(textarea).editor.getInlineSpellChecker(false);
+ ok(isc, "Inline spell checker should exist after focus and spell check");
+ let spellchecker = isc.spellChecker;
+
+ spellchecker.setCurrentDictionaries(["en-US"]).then(() => {
+ let currentDictionaries = spellchecker.getCurrentDictionaries();
+
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ is(currentDictionaries[0], "en-US", "expected en-US");
+ is(getMisspelledWords(editor), "heuteisteinguter", "some misspelled words expected: heuteisteinguter");
+ spellchecker.setCurrentDictionaries(["en-US", "de-DE"]).then(() => {
+ textarea.blur();
+ textarea.focus();
+ maybeOnSpellCheck(textarea, () => {
+ currentDictionaries = spellchecker.getCurrentDictionaries();
+
+ is(currentDictionaries.length, 2, "expected two dictionaries");
+ is(currentDictionaries[0], "en-US", "expected en-US");
+ is(currentDictionaries[1], "de-DE", "expected de-DE");
+ is(getMisspelledWords(editor), "", "No misspelled words expected");
+
+ spellchecker.setCurrentDictionaries(["de-DE"]).then(() => {
+ textarea.blur();
+ textarea.focus();
+ maybeOnSpellCheck(textarea, async function() {
+ currentDictionaries = spellchecker.getCurrentDictionaries();
+
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ is(currentDictionaries[0], "de-DE", "expected de-DE");
+ is(getMisspelledWords(editor), "todayisagoodday", "some misspelled words expected: todayisagoodday");
+
+ // Remove the fake de_DE dictionary again.
+ await script.sendQuery("destroy");
+
+ // This will clear the content preferences and reset "spellchecker.dictionary".
+ spellchecker.setCurrentDictionaries([]).then(() => {
+ SimpleTest.finish();
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+}
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1418629.html b/editor/spellchecker/tests/test_bug1418629.html
new file mode 100644
index 0000000000..3f2c906913
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1418629.html
@@ -0,0 +1,210 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Mozilla bug 1418629</title>
+ <link rel=stylesheet href="/tests/SimpleTest/test.css">
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/editor/spellchecker/tests/spellcheck.js"></script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1418629">Mozilla Bug 1418629</a>
+<p id="display"></p>
+<div id="content" style="display: none;">
+
+</div>
+
+<input id="input1" spellcheck="true">
+<textarea id="textarea1"></textarea>
+<div id="edit1" contenteditable=true></div>
+
+<script>
+const { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+SimpleTest.waitForExplicitFinish();
+
+function getEditor(input) {
+ if (input instanceof HTMLInputElement ||
+ input instanceof HTMLTextAreaElement) {
+ return SpecialPowers.wrap(input).editor;
+ }
+
+ return SpecialPowers.wrap(window).docShell.editor;
+}
+
+function resetEditableContent(input) {
+ if (input instanceof HTMLInputElement ||
+ input instanceof HTMLTextAreaElement) {
+ input.value = "";
+ return;
+ }
+ input.innerHTML = "";
+}
+
+async function test_with_single_quote(input) {
+ let misspeltWords = [];
+
+ input.focus();
+ resetEditableContent(input);
+
+ synthesizeKey("d");
+ synthesizeKey("o");
+ synthesizeKey("e");
+ synthesizeKey("s");
+
+ await new Promise((resolve) => { maybeOnSpellCheck(input, resolve); });
+ let editor = getEditor(input);
+ // isSpellingCheckOk is defined in spellcheck.js
+ ok(isSpellingCheckOk(editor, misspeltWords), "no misspelt words");
+
+ synthesizeKey("n");
+ synthesizeKey("\'");
+ is(input.value || input.textContent, "doesn\'", "");
+
+ await new Promise((resolve) => { maybeOnSpellCheck(input, resolve); });
+ // XXX This won't work since mozInlineSpellWordUtil::SplitDOM removes
+ // last single quote unfortunately that is during inputting.
+ // isSpellingCheckOk is defined in spellcheck.js
+ todo_is(isSpellingCheckOk(editor, misspeltWords, true), true,
+ "don't run spellchecker during inputting word");
+
+ synthesizeKey(" ");
+ is(input.value || input.textContent, "doesn\' ", "");
+
+ await new Promise((resolve) => { maybeOnSpellCheck(input, resolve); });
+ misspeltWords.push("doesn");
+ // isSpellingCheckOk is defined in spellcheck.js
+ ok(isSpellingCheckOk(editor, misspeltWords), "should run spellchecker");
+}
+
+async function test_with_twice_characters(input, ch) {
+ let misspeltWords = [];
+
+ input.focus();
+ resetEditableContent(input);
+
+ synthesizeKey("d");
+ synthesizeKey("o");
+ synthesizeKey("e");
+ synthesizeKey("s");
+ synthesizeKey("n");
+ synthesizeKey(ch);
+ synthesizeKey(ch);
+ is(input.value || input.textContent, "doesn" + ch + ch, "");
+
+ // trigger spellchecker
+ synthesizeKey(" ");
+
+ await new Promise((resolve) => { maybeOnSpellCheck(input, resolve); });
+ misspeltWords.push("doesn");
+ let editor = getEditor(input);
+ // isSpellingCheckOk is defined in spellcheck.js
+ ok(isSpellingCheckOk(editor, misspeltWords), "should run spellchecker");
+}
+
+async function test_between_single_quote(input) {
+ let misspeltWords = [];
+
+ input.focus();
+ resetEditableContent(input);
+
+ synthesizeKey("\'");
+ synthesizeKey("t");
+ synthesizeKey("e");
+ synthesizeKey("s");
+ synthesizeKey("t");
+ synthesizeKey("\'");
+
+ await new Promise((resolve) => { maybeOnSpellCheck(input, resolve); });
+ let editor = getEditor(input);
+ ok(isSpellingCheckOk(editor, misspeltWords),
+ "don't run spellchecker between single qoute");
+}
+
+async function test_with_email(input) {
+ let misspeltWords = [];
+
+ input.focus();
+ resetEditableContent(input);
+
+ synthesizeKey("t");
+ synthesizeKey("t");
+ synthesizeKey("t");
+ synthesizeKey("t");
+ synthesizeKey("@");
+ synthesizeKey("t");
+ synthesizeKey("t");
+ synthesizeKey("t");
+ synthesizeKey("t");
+ synthesizeKey(".");
+ synthesizeKey("c");
+ synthesizeKey("o");
+ synthesizeKey("m");
+
+ await new Promise((resolve) => { maybeOnSpellCheck(input, resolve); });
+ let editor = getEditor(input);
+ ok(isSpellingCheckOk(editor, misspeltWords),
+ "don't run spellchecker for email address");
+
+ synthesizeKey(" ");
+
+ await new Promise((resolve) => { maybeOnSpellCheck(input, resolve); });
+ ok(isSpellingCheckOk(editor, misspeltWords),
+ "no misspelt words due to email address");
+}
+
+async function test_with_url(input) {
+ let misspeltWords = [];
+
+ input.focus();
+ resetEditableContent(input);
+
+ synthesizeKey("h");
+ synthesizeKey("t");
+ synthesizeKey("t");
+ synthesizeKey("p");
+ synthesizeKey(":");
+ synthesizeKey("/");
+ synthesizeKey("/");
+ synthesizeKey("t");
+ synthesizeKey("t");
+ synthesizeKey("t");
+ synthesizeKey("t");
+ synthesizeKey(".");
+ synthesizeKey("c");
+ synthesizeKey("o");
+ synthesizeKey("m");
+
+ await new Promise((resolve) => { maybeOnSpellCheck(input, resolve); });
+ let editor = getEditor(input);
+ ok(isSpellingCheckOk(editor, misspeltWords),
+ "don't run spellchecker for URL");
+
+ synthesizeKey(" ");
+
+ await new Promise((resolve) => { maybeOnSpellCheck(input, resolve); });
+ ok(isSpellingCheckOk(editor, misspeltWords),
+ "no misspelt words due to URL");
+}
+
+SimpleTest.waitForFocus(() => {
+ for (let n of ["input1", "textarea1", "edit1"]) {
+ add_task(test_with_single_quote.bind(null,
+ document.getElementById(n)));
+ add_task(test_with_twice_characters.bind(null,
+ document.getElementById(n),
+ "\'"));
+ add_task(test_with_twice_characters.bind(null,
+ document.getElementById(n),
+ String.fromCharCode(0x2019)));
+ add_task(test_between_single_quote.bind(null,
+ document.getElementById(n)));
+ add_task(test_with_email.bind(null, document.getElementById(n)));
+ add_task(test_with_url.bind(null, document.getElementById(n)));
+ }
+});
+</script>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1497480.html b/editor/spellchecker/tests/test_bug1497480.html
new file mode 100644
index 0000000000..a32e67370d
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1497480.html
@@ -0,0 +1,94 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1497480
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1497480</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="spellcheck.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1497480">Mozilla Bug 1497480</a>
+<p id="display"></p>
+
+<div id="outOfTarget" contenteditable>Bug 1497480</div>
+<div id="light"></div>
+
+<script>
+
+/** Test for Bug 1497480 **/
+let gMisspeltWords = [];
+let { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+const template = document.createElement("template");
+template.innerHTML = `<div id="target" contenteditable>Test</div>`;
+
+let shadow = document.getElementById("light").attachShadow({mode: "closed"});
+shadow.appendChild(template.content.cloneNode(true));
+
+let target = shadow.getElementById("target");
+let outOfTarget = document.getElementById("outOfTarget");
+
+function getEditor() {
+ var win = window;
+ var editingSession = SpecialPowers.wrap(win).docShell.editingSession;
+ return editingSession.getEditorForWindow(win);
+}
+
+// Wait for the page to be ready for testing
+add_task(async function() {
+ await new Promise((resolve) => {
+ SimpleTest.waitForFocus(() => {
+ SimpleTest.executeSoon(resolve);
+ }, window);
+ });
+
+ // Wait for first full spell-checking.
+ synthesizeMouseAtCenter(outOfTarget, {}, window);
+ await new Promise((resolve) => {
+ maybeOnSpellCheck(outOfTarget, function() {
+ resolve();
+ });
+ });
+});
+
+// Should perform spell-checking when anchor navigates away from ShadowDOM.
+add_task(async function() {
+ synthesizeMouseAtCenter(target, {}, window);
+ sendString(" spellechek");
+ gMisspeltWords.push("spellechek");
+ synthesizeMouseAtCenter(outOfTarget, {}, window);
+ await new Promise((resolve) => {
+ maybeOnSpellCheck(target, function() {
+ ok(isSpellingCheckOk(getEditor(), gMisspeltWords),
+ "Spell-checking should be performed when anchor navigates away from ShadowDOM");
+ SimpleTest.executeSoon(resolve);
+ });
+ });
+});
+
+// Should perform spell-checking when pressing enter in contenteditable in ShadowDOM.
+add_task(async function() {
+ synthesizeMouseAtCenter(target, {}, window);
+ sendString(" spellechck");
+ gMisspeltWords.push("spellechck");
+ synthesizeKey("KEY_Enter", {}, window);
+ await new Promise((resolve) => {
+ maybeOnSpellCheck(target, function() {
+ ok(isSpellingCheckOk(getEditor(), gMisspeltWords),
+ "Spell-checking should be performed when pressing enter in contenteditable in ShadowDOM");
+ SimpleTest.executeSoon(resolve);
+ });
+ });
+});
+
+</script>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1602526.html b/editor/spellchecker/tests/test_bug1602526.html
new file mode 100644
index 0000000000..62be07a53c
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1602526.html
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Mozilla bug 1602526</title>
+ <link rel=stylesheet href="/tests/SimpleTest/test.css">
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/editor/spellchecker/tests/spellcheck.js"></script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1602526">Mozilla Bug 1602526</a>
+<p id="display"></p>
+<div id="content" style="display: none;">
+
+</div>
+
+<div id="contenteditable" contenteditable=true>kkkk&#xf6;kkkk</div>
+
+<script>
+const { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+SimpleTest.waitForExplicitFinish();
+
+function getEditor() {
+ return SpecialPowers.wrap(window).docShell.editor;
+}
+
+SimpleTest.waitForFocus(async () => {
+ let contenteditable = document.getElementById("contenteditable");
+ let misspeltWords = [];
+ misspeltWords.push("kkkk\u00f6kkkk");
+
+ contenteditable.focus();
+ window.getSelection().collapse(contenteditable.firstChild, contenteditable.firstChild.length);
+
+ synthesizeKey(" ");
+
+ // Run spell checker
+ await new Promise((resolve) => { maybeOnSpellCheck(contenteditable, resolve); });
+
+ synthesizeKey("a");
+ synthesizeKey("a");
+ synthesizeKey("a");
+
+ await new Promise((resolve) => { maybeOnSpellCheck(contenteditable, resolve); });
+ let editor = getEditor();
+ // isSpellingCheckOk is defined in spellcheck.js
+ // eslint-disable-next-line no-undef
+ ok(isSpellingCheckOk(editor, misspeltWords), "correct word is seleced as misspell");
+
+ SimpleTest.finish();
+});
+</script>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1761273.html b/editor/spellchecker/tests/test_bug1761273.html
new file mode 100644
index 0000000000..65e3618194
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1761273.html
@@ -0,0 +1,96 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1761273
+-->
+<head>
+ <title>Test for Bug 1761273</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1761273">Mozilla Bug 1761273</a>
+<p id="display"></p>
+</div>
+
+<textarea id="editor" lang="en-US">heute ist ein guter Tag - today is a good day</textarea>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+const Ci = SpecialPowers.Ci;
+
+let {
+ getDictionaryContentPref,
+ onSpellCheck,
+} = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+/** Test for Bug 1402822 **/
+SimpleTest.waitForExplicitFinish();
+addLoadEvent(start);
+async function start() {
+ let script = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ // eslint-disable-next-line mozilla/use-services
+ let dir = Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties)
+ .get("CurWorkD", Ci.nsIFile);
+ dir.append("tests");
+ dir.append("editor");
+ dir.append("spellchecker");
+ dir.append("tests");
+
+ let hunspell = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine);
+
+ // Install de-DE dictionary.
+ let de_DE = dir.clone();
+ de_DE.append("de-DE");
+ hunspell.addDirectory(de_DE);
+
+ addMessageListener("destroy", () => hunspell.removeDirectory(de_DE));
+ addMessageListener("de_DE-exists", () => de_DE.exists());
+ });
+ is(await script.sendQuery("de_DE-exists"), true,
+ "true expected (de_DE directory should exist)");
+
+ let textarea = document.getElementById("editor");
+ textarea.focus();
+
+ onSpellCheck(textarea, async () => {
+ let isc = SpecialPowers.wrap(textarea).editor.getInlineSpellChecker(true);
+ ok(isc, "Inline spell checker should exist after focus and spell check");
+ let spellchecker = isc.spellChecker;
+
+ // Setting the language to the language of the texteditor should not set the
+ // content preference.
+ await spellchecker.setCurrentDictionaries(["en-US"]);
+ let dictionaryContentPref = await getDictionaryContentPref();
+ is(dictionaryContentPref, "", "Content pref should be empty");
+
+ await spellchecker.setCurrentDictionaries(["en-US", "de-DE"]);
+ dictionaryContentPref = await getDictionaryContentPref();
+ is(dictionaryContentPref, "en-US,de-DE,", "Content pref should be en-US,de-DE,");
+
+ await spellchecker.setCurrentDictionaries(["de-DE"]);
+ dictionaryContentPref = await getDictionaryContentPref();
+ is(dictionaryContentPref, "de-DE,", "Content pref should be de-DE,");
+
+ // Remove the fake de_DE dictionary again.
+ await script.sendQuery("destroy");
+
+ // This will clear the content preferences and reset "spellchecker.dictionary".
+ await spellchecker.setCurrentDictionaries([]);
+ dictionaryContentPref = await getDictionaryContentPref();
+ is(dictionaryContentPref, "", "Content pref should be empty");
+
+ SimpleTest.finish();
+ });
+}
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1773802.html b/editor/spellchecker/tests/test_bug1773802.html
new file mode 100644
index 0000000000..7e9866817d
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1773802.html
@@ -0,0 +1,96 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1773802
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1773802</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1773802">Mozilla Bug 1773802</a>
+<p id="display"></p>
+</div>
+
+<textarea id="editor">correct правильный, incarrect непровильный</textarea>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+const Ci = SpecialPowers.Ci;
+
+let { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+function getMisspelledWords(editor) {
+ return editor.selectionController.getSelection(Ci.nsISelectionController.SELECTION_SPELLCHECK).toString();
+}
+
+/** Test for Bug 1773802 **/
+SimpleTest.waitForExplicitFinish();
+addLoadEvent(start);
+async function start() {
+ let script = SpecialPowers.loadChromeScript(() => {
+ /* eslint-env mozilla/chrome-script */
+ // eslint-disable-next-line mozilla/use-services
+ let dir = Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties)
+ .get("CurWorkD", Ci.nsIFile);
+ dir.append("tests");
+ dir.append("editor");
+ dir.append("spellchecker");
+ dir.append("tests");
+
+ let hunspell = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine);
+
+ // Install ru-RU dictionary.
+ let ru_RU = dir.clone();
+ ru_RU.append("ru-RU");
+ hunspell.addDirectory(ru_RU);
+
+ addMessageListener("destroy", () => hunspell.removeDirectory(ru_RU));
+ addMessageListener("ru_RU-exists", () => ru_RU.exists());
+ });
+ is(await script.sendQuery("ru_RU-exists"), true,
+ "true expected (ru_RU directory should exist)");
+
+ let textarea = document.getElementById("editor");
+ let editor = SpecialPowers.wrap(textarea).editor;
+ textarea.focus();
+
+ maybeOnSpellCheck(textarea, () => {
+ let isc = SpecialPowers.wrap(textarea).editor.getInlineSpellChecker(false);
+ ok(isc, "Inline spell checker should exist after focus and spell check");
+ let spellchecker = isc.spellChecker;
+
+ spellchecker.setCurrentDictionaries(["en-US", "ru-RU"]).then(async () => {
+ textarea.blur();
+ textarea.focus();
+ maybeOnSpellCheck(textarea, async function() {
+ let currentDictionaries = spellchecker.getCurrentDictionaries();
+
+ is(currentDictionaries.length, 2, "expected two dictionaries");
+ is(currentDictionaries[0], "en-US", "expected en-US");
+ is(currentDictionaries[1], "ru-RU", "expected ru-RU");
+ is(getMisspelledWords(editor), "incarrectнепровильный", "some misspelled words expected: incarrect непровильный");
+
+ // Remove the fake ru_RU dictionary again.
+ await script.sendQuery("destroy");
+
+ // This will clear the content preferences and reset "spellchecker.dictionary".
+ spellchecker.setCurrentDictionaries([]).then(() => {
+ SimpleTest.finish();
+ });
+ });
+ });
+ });
+}
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug1837268.html b/editor/spellchecker/tests/test_bug1837268.html
new file mode 100644
index 0000000000..1467a328b5
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug1837268.html
@@ -0,0 +1,79 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Mozilla bug 1837268</title>
+ <link rel=stylesheet href="/tests/SimpleTest/test.css">
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/editor/spellchecker/tests/spellcheck.js"></script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1837268">Mozilla Bug 1837268</a>
+<p id="display"></p>
+<div id="content" style="display: none;">
+
+</div>
+
+<div id="contenteditable" contenteditable=true>aabbcc</div>
+
+<script>
+const { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+SimpleTest.waitForExplicitFinish();
+
+function getEditor() {
+ return SpecialPowers.wrap(window).docShell.editor;
+}
+
+SimpleTest.waitForFocus(async () => {
+ let contenteditable = document.getElementById("contenteditable");
+ contenteditable.addEventListener("beforeinput", (ev) => {
+ ev.preventDefault();
+ let text = contenteditable.textContent;
+ const sel = window.getSelection();
+ let offset = sel.anchorOffset;
+ switch (ev.inputType) {
+ case "insertText":
+ text = text.substring(0, offset) + ev.data + text.substring(offset);
+ offset += 1;
+ break;
+ case "deleteContentBackward":
+ text = text.substring(0, offset - 1) + text.substring(offset);
+ offset -= 1;
+ break;
+ default:
+ return;
+ }
+ if (contenteditable.firstChild) {
+ contenteditable.firstChild.nodeValue = text;
+ } else {
+ contenteditable.textContent = text;
+ }
+ sel.collapse(contenteditable.firstChild ?? contenteditable, offset);
+ });
+
+ let misspelledWords = [];
+ misspelledWords.push("aabbc"); // One c removed.
+
+ contenteditable.focus();
+ window.getSelection().collapse(contenteditable.firstChild, contenteditable.firstChild.length);
+
+ // Run spell checker
+ await new Promise((resolve) => { maybeOnSpellCheck(contenteditable, resolve); });
+
+ synthesizeKey("KEY_Backspace");
+ synthesizeKey(" ");
+
+ await new Promise((resolve) => { maybeOnSpellCheck(contenteditable, resolve); });
+ let editor = getEditor();
+ // isSpellingCheckOk is defined in spellcheck.js
+ // eslint-disable-next-line no-undef
+ ok(isSpellingCheckOk(editor, misspelledWords), "correct word is selected as misspelled");
+
+ SimpleTest.finish();
+});
+</script>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug338427.html b/editor/spellchecker/tests/test_bug338427.html
new file mode 100644
index 0000000000..20edb29012
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug338427.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=338427
+-->
+<head>
+ <title>Test for Bug 338427</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=338427">Mozilla Bug 338427</a>
+<p id="display"></p>
+<div id="content">
+<textarea id="editor" lang="testing-XX" spellcheck="true"></textarea>
+
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Bug 338427 **/
+function init() {
+ var { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ var textarea = document.getElementById("editor");
+ var editor = SpecialPowers.wrap(textarea).editor;
+ var spellchecker = editor.getInlineSpellChecker(true);
+ spellchecker.enableRealTimeSpell = true;
+ textarea.focus();
+
+ onSpellCheck(textarea, function() {
+ var list = spellchecker.spellChecker.GetDictionaryList();
+ ok(!!list.length, "At least one dictionary should be present");
+
+ var lang = list[0];
+ spellchecker.spellChecker.setCurrentDictionaries([lang]).then(() => {
+ onSpellCheck(textarea, function() {
+ try {
+ var dictionaries =
+ spellchecker.spellChecker.getCurrentDictionaries();
+ } catch (e) {}
+ is(dictionaries.length, 1, "Expected one dictionary");
+ is(dictionaries[0], lang, "Unexpected spell check dictionary");
+
+ // This will clear the content preferences and reset "spellchecker.dictionary".
+ spellchecker.spellChecker.setCurrentDictionaries([]).then(() => {
+ SimpleTest.finish();
+ });
+ });
+ });
+ });
+}
+
+SimpleTest.waitForExplicitFinish();
+addLoadEvent(init);
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug366682.html b/editor/spellchecker/tests/test_bug366682.html
new file mode 100644
index 0000000000..0191c599ab
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug366682.html
@@ -0,0 +1,65 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=366682
+-->
+<head>
+ <title>Test for Bug 366682</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="spellcheck.js"></script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=366682">Mozilla Bug 366682</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+</div>
+<pre id="test">
+<script type="application/javascript">
+
+/** Test for Bug 366682 **/
+
+SimpleTest.waitForExplicitFinish();
+addLoadEvent(runTest);
+
+var gMisspeltWords;
+
+function getEdit() {
+ return document.getElementById("edit");
+}
+
+function editDoc() {
+ return getEdit().contentDocument;
+}
+
+function getEditor() {
+ var win = editDoc().defaultView;
+ var editingSession = SpecialPowers.wrap(win).docShell.editingSession;
+ return editingSession.getEditorForWindow(win);
+}
+
+function runTest() {
+ editDoc().body.innerHTML = "<div>errror and an other errror</div>";
+ gMisspeltWords = ["errror", "errror"];
+ editDoc().designMode = "on";
+ editDoc().defaultView.focus();
+
+ const { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ maybeOnSpellCheck(editDoc().documentElement, evalTest);
+}
+
+function evalTest() {
+ ok(isSpellingCheckOk(getEditor(), gMisspeltWords),
+ "All misspellings accounted for.");
+ SimpleTest.finish();
+}
+</script>
+</pre>
+
+<iframe id="edit" width="200" height="100" src="about:blank"></iframe>
+
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug432225.html b/editor/spellchecker/tests/test_bug432225.html
new file mode 100644
index 0000000000..19654eec06
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug432225.html
@@ -0,0 +1,72 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=432225
+-->
+<head>
+ <title>Test for Bug 432225</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="spellcheck.js"></script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=432225">Mozilla Bug 432225</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+<script type="application/javascript">
+
+/** Test for Bug 432225 **/
+
+let { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+SimpleTest.waitForExplicitFinish();
+addLoadEvent(runTest);
+
+var gMisspeltWords = [];
+
+function getEdit() {
+ return document.getElementById("edit");
+}
+
+function editDoc() {
+ return getEdit().contentDocument;
+}
+
+function getEditor() {
+ var win = editDoc().defaultView;
+ var editingSession = SpecialPowers.wrap(win).docShell.editingSession;
+ return editingSession.getEditorForWindow(win);
+}
+
+function runTest() {
+ editDoc().designMode = "on";
+ setTimeout(function() { addWords(100); }, 0);
+}
+
+function addWords(aLimit) {
+ if (aLimit == 0) {
+ ok(isSpellingCheckOk(getEditor(), gMisspeltWords),
+ "All misspellings accounted for.");
+ SimpleTest.finish();
+ return;
+ }
+ getEdit().focus();
+ sendString("aa OK ");
+ gMisspeltWords.push("aa");
+ maybeOnSpellCheck(editDoc(), function() {
+ addWords(aLimit - 1);
+ });
+}
+</script>
+</pre>
+
+<iframe id="edit" width="200" height="100" src="about:blank"></iframe>
+
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug484181.html b/editor/spellchecker/tests/test_bug484181.html
new file mode 100644
index 0000000000..dab966bcde
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug484181.html
@@ -0,0 +1,73 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=484181
+-->
+<head>
+ <title>Test for Bug 484181</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="spellcheck.js"></script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=484181">Mozilla Bug 484181</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+<script type="application/javascript">
+
+/** Test for Bug 484181 **/
+
+SimpleTest.waitForExplicitFinish();
+addLoadEvent(runTest);
+
+var gMisspeltWords;
+
+function getEditor() {
+ var win = window;
+ var editingSession = SpecialPowers.wrap(win).docShell.editingSession;
+ return editingSession.getEditorForWindow(win);
+}
+
+function append(str) {
+ var edit = document.getElementById("edit");
+ var editor = getEditor();
+ var sel = editor.selection;
+ sel.selectAllChildren(edit);
+ sel.collapseToEnd();
+ sendString(str);
+}
+
+function runTest() {
+ gMisspeltWords = ["haz", "cheezburger"];
+ var edit = document.getElementById("edit");
+ edit.focus();
+
+ const { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ maybeOnSpellCheck(edit, function() {
+ ok(isSpellingCheckOk(getEditor(), gMisspeltWords),
+ "All misspellings before editing are accounted for.");
+
+ append(" becaz I'm a lulcat!");
+ maybeOnSpellCheck(edit, function() {
+ gMisspeltWords.push("becaz");
+ gMisspeltWords.push("lulcat");
+ ok(isSpellingCheckOk(getEditor(), gMisspeltWords),
+ "All misspellings after typing are accounted for.");
+
+ SimpleTest.finish();
+ });
+ });
+}
+</script>
+</pre>
+
+<div><div></div><div id="edit" contenteditable="true">I can haz cheezburger</div></div>
+
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug596333.html b/editor/spellchecker/tests/test_bug596333.html
new file mode 100644
index 0000000000..bbd6aab7a0
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug596333.html
@@ -0,0 +1,135 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=596333
+-->
+<head>
+ <title>Test for Bug 596333</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="spellcheck.js"></script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=596333">Mozilla Bug 596333</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+<script type="application/javascript">
+
+/** Test for Bug 596333 **/
+const Ci = SpecialPowers.Ci;
+
+SimpleTest.waitForExplicitFinish();
+addLoadEvent(runTest);
+
+var gMisspeltWords;
+var onSpellCheck;
+
+function getEditor() {
+ return SpecialPowers.wrap(document.getElementById("edit")).editor;
+}
+
+function append(str) {
+ var edit = document.getElementById("edit");
+ edit.focus();
+ edit.selectionStart = edit.selectionEnd = edit.value.length;
+ sendString(str);
+}
+
+function getLoadContext() {
+ return SpecialPowers.wrap(window).docShell.QueryInterface(Ci.nsILoadContext);
+}
+
+function paste(str) {
+ var Cc = SpecialPowers.Cc;
+ var trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable);
+ trans.init(getLoadContext());
+ var s = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
+ s.data = str;
+ trans.setTransferData("text/plain", s);
+
+ let beforeInputEvent = null;
+ let inputEvent = null;
+ window.addEventListener("beforeinput", aEvent => { beforeInputEvent = aEvent; }, {once: true});
+ window.addEventListener("input", aEvent => { inputEvent = aEvent; }, {once: true});
+ getEditor().pasteTransferable(trans);
+ isnot(beforeInputEvent, null, '"beforeinput" event should be fired');
+ if (beforeInputEvent) {
+ is(beforeInputEvent.cancelable, true, '"beforeinput" event for "insertFromPaste" should be cancelable');
+ is(beforeInputEvent.inputType, "insertFromPaste", 'inputType of "beforeinput" event should be "insertFromPaste"');
+ is(beforeInputEvent.data, str, `data of "beforeinput" event should be "${str}"`);
+ is(beforeInputEvent.dataTransfer, null, 'dataTransfer of "beforeinput" event should be null on <textarea>');
+ is(beforeInputEvent.getTargetRanges().length, 0, 'getTargetRanges() of "beforeinput" event should return empty array on <textarea>');
+ }
+ is(inputEvent.type, "input", '"input" event should be fired');
+ is(inputEvent.inputType, "insertFromPaste", '"inputType of "input" event should be "insertFromPaste"');
+ is(inputEvent.data, str, `data of "input" event should be "${str}"`);
+ is(inputEvent.dataTransfer, null, 'dataTransfer of "input" event should be null on <textarea>');
+ is(inputEvent.getTargetRanges().length, 0, 'getTargetRanges() of "input" event should return empty array on <textarea>');
+}
+
+function runOnFocus() {
+ var edit = document.getElementById("edit");
+
+ gMisspeltWords = ["haz", "cheezburger"];
+ ok(isSpellingCheckOk(getEditor(), gMisspeltWords),
+ "All misspellings before editing are accounted for.");
+ append(" becaz I'm a lulcat!");
+ onSpellCheck(edit, function() {
+ gMisspeltWords.push("becaz");
+ gMisspeltWords.push("lulcat");
+ ok(isSpellingCheckOk(getEditor(), gMisspeltWords),
+ "All misspellings after typing are accounted for.");
+
+ // Now, type an invalid word, and instead of hitting "space" at the end, just blur
+ // the textarea and see if the spell check after the blur event catches it.
+ append(" workd");
+ edit.blur();
+ onSpellCheck(edit, function() {
+ gMisspeltWords.push("workd");
+ ok(isSpellingCheckOk(getEditor(), gMisspeltWords),
+ "All misspellings after blur are accounted for.");
+
+ // Also, test the case when we're entering the first word in a textarea
+ gMisspeltWords = ["workd"];
+ edit.value = "";
+ append("workd ");
+ onSpellCheck(edit, function() {
+ ok(isSpellingCheckOk(getEditor(), gMisspeltWords),
+ "Misspelling in the first entered word is accounted for.");
+
+ // Make sure that pasting would also trigger spell checking for the previous word
+ gMisspeltWords = ["workd"];
+ edit.value = "";
+ append("workd");
+ paste(" x");
+ onSpellCheck(edit, function() {
+ ok(isSpellingCheckOk(getEditor(), gMisspeltWords),
+ "Misspelling is accounted for after pasting.");
+
+ SimpleTest.finish();
+ });
+ });
+ });
+ });
+}
+
+function runTest() {
+ var edit = document.getElementById("edit");
+ edit.focus();
+
+ onSpellCheck = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ ).onSpellCheck;
+ onSpellCheck(edit, runOnFocus);
+}
+</script>
+</pre>
+
+<textarea id="edit">I can haz cheezburger</textarea>
+
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug636465.html b/editor/spellchecker/tests/test_bug636465.html
new file mode 100644
index 0000000000..aaa64c3a31
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug636465.html
@@ -0,0 +1,54 @@
+<!doctype html>
+<title>Mozilla bug 636465</title>
+<link rel=stylesheet href="/tests/SimpleTest/test.css">
+<script src="/tests/SimpleTest/EventUtils.js"></script>
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<script src="/tests/SimpleTest/WindowSnapshot.js"></script>
+<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=636465"
+ target="_blank">Mozilla Bug 636465</a>
+<input id="x" value="foobarbaz" spellcheck="true" style="background-color: transparent; border: transparent;">
+<script>
+SimpleTest.waitForExplicitFinish();
+
+function runTest() {
+ const { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ var x = document.getElementById("x");
+ x.focus();
+ onSpellCheck(x, function() {
+ x.blur();
+ var spellCheckTrue = snapshotWindow(window);
+ x.setAttribute("spellcheck", "false");
+ var spellCheckFalse = snapshotWindow(window);
+ x.setAttribute("spellcheck", "true");
+ x.focus();
+ onSpellCheck(x, function() {
+ x.blur();
+ var spellCheckTrueAgain = snapshotWindow(window);
+ x.removeAttribute("spellcheck");
+ var spellCheckNone = snapshotWindow(window);
+ var ret = compareSnapshots(spellCheckTrue, spellCheckFalse, false)[0];
+ ok(ret,
+ "Setting the spellcheck attribute to false should work");
+ if (!ret) {
+ ok(false, "\nspellCheckTrue: " + spellCheckTrue.toDataURL() + "\nspellCheckFalse: " + spellCheckFalse.toDataURL());
+ }
+ ret = compareSnapshots(spellCheckTrue, spellCheckTrueAgain, true)[0];
+ ok(ret,
+ "Setting the spellcheck attribute back to true should work");
+ if (!ret) {
+ ok(false, "\nspellCheckTrue: " + spellCheckTrue.toDataURL() + "\nspellCheckTrueAgain: " + spellCheckTrueAgain.toDataURL());
+ }
+ ret = compareSnapshots(spellCheckNone, spellCheckFalse, true)[0];
+ ok(ret,
+ "Unsetting the spellcheck attribute should work");
+ if (!ret) {
+ ok(false, "\spellCheckNone: " + spellCheckNone.toDataURL() + "\nspellCheckFalse: " + spellCheckFalse.toDataURL());
+ }
+ SimpleTest.finish();
+ });
+ });
+}
+addLoadEvent(runTest);
+</script>
diff --git a/editor/spellchecker/tests/test_bug678842.html b/editor/spellchecker/tests/test_bug678842.html
new file mode 100644
index 0000000000..b328e5a8ff
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug678842.html
@@ -0,0 +1,106 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=678842
+-->
+<head>
+ <title>Test for Bug 678842</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=678842">Mozilla Bug 678842</a>
+<p id="display"></p>
+<iframe id="content"></iframe>
+
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Bug 678842 **/
+SimpleTest.waitForExplicitFinish();
+var content = document.getElementById("content");
+// load a subframe containing an editor with a defined unknown lang. At first
+// load, it will set dictionary to en-US. At second load, it will return current
+// dictionary. So, we can check, dictionary is correctly remembered between
+// loads.
+
+var firstLoad = true;
+var script;
+
+var loadListener = async function(evt) {
+ if (firstLoad) {
+ script = SpecialPowers.loadChromeScript(function() {
+ /* eslint-env mozilla/chrome-script */
+ // eslint-disable-next-line mozilla/use-services
+ var dir = Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties)
+ .get("CurWorkD", Ci.nsIFile);
+ dir.append("tests");
+ dir.append("editor");
+ dir.append("spellchecker");
+ dir.append("tests");
+
+ var hunspell = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine);
+
+ // Install en-GB dictionary.
+ let en_GB = dir.clone();
+ en_GB.append("en-GB");
+ hunspell.addDirectory(en_GB);
+
+ addMessageListener("en_GB-exists", () => en_GB.exists());
+ addMessageListener("destroy", () => hunspell.removeDirectory(en_GB));
+ });
+ is(await script.sendQuery("en_GB-exists"), true,
+ "true expected (en-GB directory should exist)");
+ }
+
+ var doc = evt.target.contentDocument;
+ var elem = doc.getElementById("textarea");
+ var editor = SpecialPowers.wrap(elem).editor;
+ editor.setSpellcheckUserOverride(true);
+ var inlineSpellChecker = editor.getInlineSpellChecker(true);
+
+ const { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ onSpellCheck(elem, async function() {
+ let spellchecker = inlineSpellChecker.spellChecker;
+ let currentDictionaries = spellchecker.getCurrentDictionaries();
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ let currentDictionary = currentDictionaries[0];
+
+ if (firstLoad) {
+ firstLoad = false;
+
+ // First time around, the dictionary defaults to the locale.
+ is(currentDictionary, "en-US", "unexpected lang " + currentDictionary + " instead of en-US");
+
+ // Select en-GB.
+ spellchecker.setCurrentDictionaries(["en-GB"]).then(() => {
+ content.src = "http://mochi.test:8888/tests/editor/spellchecker/tests/bug678842_subframe.html?firstload=false";
+ });
+ } else {
+ is(currentDictionary, "en-GB", "unexpected lang " + currentDictionary + " instead of en-GB");
+ content.removeEventListener("load", loadListener);
+
+ // Remove the fake en-GB dictionary again, since it's otherwise picked up by later tests.
+ await script.sendQuery("destroy");
+
+ // This will clear the content preferences and reset "spellchecker.dictionary".
+ spellchecker.setCurrentDictionaries([]).then( () => {
+ SimpleTest.finish();
+ });
+ }
+ });
+};
+
+content.addEventListener("load", loadListener);
+
+content.src = "http://mochi.test:8888/tests/editor/spellchecker/tests/bug678842_subframe.html?firstload=true";
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug697981.html b/editor/spellchecker/tests/test_bug697981.html
new file mode 100644
index 0000000000..8ef5dbcd92
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug697981.html
@@ -0,0 +1,137 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=697981
+-->
+<head>
+ <title>Test for Bug 697981</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=697981">Mozilla Bug 697981</a>
+<p id="display"></p>
+</div>
+
+<textarea id="de-DE" lang="de-DE" onfocus="deFocus()">German heute ist ein guter Tag</textarea>
+<textarea id="en-US" lang="en-US" onfocus="enFocus()">Nogoodword today is a nice day</textarea>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+function getMisspelledWords(editor) {
+ return editor.selectionController.getSelection(SpecialPowers.Ci.nsISelectionController.SELECTION_SPELLCHECK).toString();
+}
+
+var elem_de;
+var editor_de;
+var script;
+
+var { maybeOnSpellCheck, onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+/** Test for Bug 697981 **/
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(async function() {
+ script = SpecialPowers.loadChromeScript(function() {
+ /* eslint-env mozilla/chrome-script */
+ // eslint-disable-next-line mozilla/use-services
+ var dir = Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties)
+ .get("CurWorkD", Ci.nsIFile);
+ dir.append("tests");
+ dir.append("editor");
+ dir.append("spellchecker");
+ dir.append("tests");
+
+ var hunspell = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine);
+
+ // Install de-DE dictionary.
+ var de_DE = dir.clone();
+ de_DE.append("de-DE");
+ hunspell.addDirectory(de_DE);
+
+ addMessageListener("de_DE-exists", () => de_DE.exists());
+ addMessageListener("destroy", () => hunspell.removeDirectory(de_DE));
+ });
+ is(await script.sendQuery("de_DE-exists"), true,
+ "true expected (de_DE directory should exist)");
+
+ document.getElementById("de-DE").focus();
+});
+
+function deFocus() {
+ elem_de = document.getElementById("de-DE");
+ editor_de = SpecialPowers.wrap(elem_de).editor;
+ editor_de.setSpellcheckUserOverride(true);
+ var inlineSpellChecker = editor_de.getInlineSpellChecker(true);
+
+ maybeOnSpellCheck(elem_de, function() {
+ var spellchecker = inlineSpellChecker.spellChecker;
+ try {
+ var currentDictionaries = spellchecker.getCurrentDictionaries();
+ } catch (e) {}
+
+ // Check that the German dictionary is loaded and that the spell check has worked.
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ is(currentDictionaries[0], "de-DE", "expected de-DE");
+ is(getMisspelledWords(editor_de), "German", "one misspelled word expected: German");
+
+ // Now focus the other textarea, which requires English spelling.
+ document.getElementById("en-US").focus();
+ });
+}
+
+function enFocus() {
+ var elem_en = document.getElementById("en-US");
+ var editor_en = SpecialPowers.wrap(elem_en).editor;
+ editor_en.setSpellcheckUserOverride(true);
+ var inlineSpellChecker = editor_en.getInlineSpellChecker(true);
+
+ onSpellCheck(elem_en, async function() {
+ let spellchecker = inlineSpellChecker.spellChecker;
+ let currentDictionaries = spellchecker.getCurrentDictionaries();
+
+ // Check that the English dictionary is loaded and that the spell check has worked.
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ is(currentDictionaries[0], "en-US", "expected en-US");
+ is(getMisspelledWords(editor_en), "Nogoodword", "one misspelled word expected: Nogoodword");
+
+ // So far all was boring. The important thing is whether the spell check result
+ // in the de-DE editor is still the same. After losing focus, no spell check
+ // updates should take place there.
+ is(getMisspelledWords(editor_de), "German", "one misspelled word expected: German");
+
+ // Remove the fake de_DE dictionary again.
+ await script.sendQuery("destroy");
+
+ // Focus again, so the spelling gets updated, but before we need to kill the focus handler.
+ elem_de.onfocus = null;
+ elem_de.blur();
+ elem_de.focus();
+
+ // After removal, the de_DE editor should refresh the spelling with en-US.
+ maybeOnSpellCheck(elem_de, function() {
+ spellchecker = inlineSpellChecker.spellChecker;
+ try {
+ currentDictionaries = spellchecker.getCurrentDictionaries();
+ } catch (e) {}
+
+ // Check that the default English dictionary is loaded and that the spell check has worked.
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ is(currentDictionaries[0], "en-US", "expected en-US");
+ // eslint-disable-next-line no-useless-concat
+ is(getMisspelledWords(editor_de), "heute" + "ist" + "ein" + "guter",
+ "some misspelled words expected: heute ist ein guter");
+
+ SimpleTest.finish();
+ });
+ });
+}
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_bug717433.html b/editor/spellchecker/tests/test_bug717433.html
new file mode 100644
index 0000000000..322e0d47d8
--- /dev/null
+++ b/editor/spellchecker/tests/test_bug717433.html
@@ -0,0 +1,111 @@
+<!DOCTYPE html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=717433
+-->
+<head>
+ <title>Test for Bug 717433</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=717433">Mozilla Bug 717433</a>
+<p id="display"></p>
+<iframe id="content"></iframe>
+
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Bug 717433 **/
+SimpleTest.waitForExplicitFinish();
+var content = document.getElementById("content");
+// Load a subframe containing an editor with language "en". At first
+// load, it will set the dictionary to en-GB or en-US. We set the other one.
+// At second load, it will return the current dictionary. We can check that the
+// dictionary is correctly remembered between loads.
+
+var firstLoad = true;
+var expected = "";
+var script;
+
+var loadListener = async function(evt) {
+ if (firstLoad) {
+ script = SpecialPowers.loadChromeScript(function() {
+ /* eslint-env mozilla/chrome-script */
+ // eslint-disable-next-line mozilla/use-services
+ var dir = Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties)
+ .get("CurWorkD", Ci.nsIFile);
+ dir.append("tests");
+ dir.append("editor");
+ dir.append("spellchecker");
+ dir.append("tests");
+
+ var hunspell = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine);
+
+ // Install en-GB dictionary.
+ var en_GB = dir.clone();
+ en_GB.append("en-GB");
+ hunspell.addDirectory(en_GB);
+
+ addMessageListener("en_GB-exists", () => en_GB.exists());
+ addMessageListener("destroy", () => hunspell.removeDirectory(en_GB));
+ });
+ is(await script.sendQuery("en_GB-exists"), true,
+ "true expected (en-GB directory should exist)");
+ }
+
+ var doc = evt.target.contentDocument;
+ var elem = doc.getElementById("textarea");
+ var editor = SpecialPowers.wrap(elem).editor;
+ editor.setSpellcheckUserOverride(true);
+ var inlineSpellChecker = editor.getInlineSpellChecker(true);
+
+ const { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ onSpellCheck(elem, async function() {
+ let spellchecker = inlineSpellChecker.spellChecker;
+ let currentDictionaries = spellchecker.getCurrentDictionaries();
+
+ is(currentDictionaries.length, 1, "expected one dictionary");
+ let currentDictionary = currentDictionaries[0];
+
+ if (firstLoad) {
+ firstLoad = false;
+
+ // First time around, we get a random dictionary based on the language "en".
+ if (currentDictionary == "en-GB") {
+ expected = "en-US";
+ } else if (currentDictionary == "en-US") {
+ expected = "en-GB";
+ } else {
+ is(true, false, "Neither en-US nor en-GB are current");
+ }
+ spellchecker.setCurrentDictionaries([expected]).then(() => {
+ content.src = "http://mochi.test:8888/tests/editor/spellchecker/tests/bug717433_subframe.html?firstload=false";});
+ } else {
+ is(currentDictionary, expected, expected + " expected");
+ content.removeEventListener("load", loadListener);
+
+ // Remove the fake en-GB dictionary again, since it's otherwise picked up by later tests.
+ await script.sendQuery("destroy");
+
+ // This will clear the content preferences and reset "spellchecker.dictionary".
+ spellchecker.setCurrentDictionaries([]).then(() => {
+ SimpleTest.finish();
+ });
+ }
+ });
+};
+
+content.addEventListener("load", loadListener);
+
+content.src = "http://mochi.test:8888/tests/editor/spellchecker/tests/bug717433_subframe.html?firstload=true";
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_multiple_content_languages.html b/editor/spellchecker/tests/test_multiple_content_languages.html
new file mode 100644
index 0000000000..49d3bb5ace
--- /dev/null
+++ b/editor/spellchecker/tests/test_multiple_content_languages.html
@@ -0,0 +1,176 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for multiple Content-Language values</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<p id="display"></p>
+<iframe id="content"></iframe>
+
+<pre id="test">
+<script class="testbody">
+
+/** Test for multiple Content-Language values **/
+/** Visit the elements defined above and check the dictionaries we got **/
+SimpleTest.waitForExplicitFinish();
+var content = document.getElementById("content");
+
+var tests = [
+ // text area, value of spellchecker.dictionary, result.
+ // Result: Document language.
+ [ "none", "", ["en-US", "en-GB"] ],
+
+ // Result: Element language.
+ [ "en-GB", "", ["en-GB"] ],
+ // Result: Random en-* or en-US (if application locale is en-US).
+ [ "en-ZA-not-avail", "", ["*"] ],
+ [ "en", "", ["*"] ],
+ // Result: Locale.
+ [ "ko-not-avail", "", ["en-US"] ],
+
+ // Result: Document language, plus preference value in all cases.
+ [ "none", "en-AU", ["en-US", "en-GB", "en-AU"] ],
+ [ "en-ZA-not-avail", "en-AU", ["en-AU"] ],
+ [ "ko-not-avail", "en-AU", ["en-AU"] ],
+
+ // Result: Document language, plus first matching preference language.
+ [ "none", "en-AU,en-US", ["en-US", "en-GB", "en-AU"] ],
+ // Result: First matching preference language.
+ [ "en-ZA-not-avail", "en-AU,en-US", ["en-AU"] ],
+ // Result: Fall back to preference languages.
+ [ "ko-not-avail", "en-AU,en-US", ["en-AU", "en-US"] ],
+
+ // Result: Random en-*.
+ [ "en-ZA-not-avail", "de-DE", ["*"] ],
+ // Result: Preference value.
+ [ "ko-not-avail", "de-DE", ["de-DE"] ],
+ ];
+
+var loadCount = 0;
+var retrying = false;
+var script;
+
+var loadListener = async function(evt) {
+ if (loadCount == 0) {
+ script = SpecialPowers.loadChromeScript(function() {
+ /* eslint-env mozilla/chrome-script */
+ // eslint-disable-next-line mozilla/use-services
+ var dir = Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties)
+ .get("CurWorkD", Ci.nsIFile);
+ dir.append("tests");
+ dir.append("editor");
+ dir.append("spellchecker");
+ dir.append("tests");
+
+ var hunspell = Cc["@mozilla.org/spellchecker/engine;1"]
+ .getService(Ci.mozISpellCheckingEngine);
+
+ // Install en-GB, en-AU and de-DE dictionaries.
+ var en_GB = dir.clone();
+ var en_AU = dir.clone();
+ var de_DE = dir.clone();
+ en_GB.append("en-GB");
+ en_AU.append("en-AU");
+ de_DE.append("de-DE");
+ hunspell.addDirectory(en_GB);
+ hunspell.addDirectory(en_AU);
+ hunspell.addDirectory(de_DE);
+
+ addMessageListener("check-existence",
+ () => [en_GB.exists(), en_AU.exists(),
+ de_DE.exists()]);
+ addMessageListener("destroy", () => {
+ hunspell.removeDirectory(en_GB);
+ hunspell.removeDirectory(en_AU);
+ hunspell.removeDirectory(de_DE);
+ });
+ });
+ var existenceChecks = await script.sendQuery("check-existence");
+ is(existenceChecks[0], true, "true expected (en-GB directory should exist)");
+ is(existenceChecks[1], true, "true expected (en-AU directory should exist)");
+ is(existenceChecks[2], true, "true expected (de-DE directory should exist)");
+ }
+
+ SpecialPowers.pushPrefEnv({set: [["spellchecker.dictionary", tests[loadCount][1]]]},
+ function() { continueTest(evt); });
+};
+
+function continueTest(evt) {
+ var doc = evt.target.contentDocument;
+ var elem = doc.getElementById(tests[loadCount][0]);
+ var editor = SpecialPowers.wrap(elem).editor;
+ editor.setSpellcheckUserOverride(true);
+ var inlineSpellChecker = editor.getInlineSpellChecker(true);
+ const is_en_US = SpecialPowers.Services.locale.appLocaleAsBCP47 == "en-US";
+
+ const { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ onSpellCheck(elem, async function() {
+ var spellchecker = inlineSpellChecker.spellChecker;
+ let currentDictionaries;
+ try {
+ currentDictionaries = spellchecker.getCurrentDictionaries();
+ } catch (e) {}
+
+ if (!currentDictionaries && !retrying) {
+ // It's possible for an asynchronous font-list update to cause a reflow
+ // that disrupts the async spell-check and results in not getting a
+ // current dictionary here; if that happens, we retry the same testcase
+ // by reloading the iframe without bumping loadCount.
+ info(`No current dictionary: retrying testcase ${loadCount}`);
+ retrying = true;
+ } else {
+ let expectedDictionaries = tests[loadCount][2];
+ let dictionaryArray = Array.from(currentDictionaries);
+ is(
+ dictionaryArray.length,
+ expectedDictionaries.length,
+ "Expected matching dictionary count"
+ );
+ if (expectedDictionaries[0] != "*") {
+ ok(
+ dictionaryArray.every(dict => expectedDictionaries.includes(dict)),
+ "active dictionaries should match expectation"
+ );
+ } else if (is_en_US && tests[loadCount][0].startsWith("en")) {
+ // Current application locale is en-US and content lang is en or
+ // en-unknown, so we should use en-US dictionary as default.
+ is(
+ dictionaryArray[0],
+ "en-US",
+ "expected en-US that is application locale"
+ );
+ } else {
+ let dict = dictionaryArray[0];
+ var gotEn = (dict == "en-GB" || dict == "en-AU" || dict == "en-US");
+ is(gotEn, true, "expected en-AU or en-GB or en-US");
+ }
+
+ loadCount++;
+ retrying = false;
+ }
+
+ if (loadCount < tests.length) {
+ // Load the iframe again.
+ content.src = "http://mochi.test:8888/tests/editor/spellchecker/tests/multiple_content_languages_subframe.html?firstload=false";
+ } else {
+ // Remove the fake dictionaries again, since it's otherwise picked up by later tests.
+ await script.sendQuery("destroy");
+
+ SimpleTest.finish();
+ }
+ });
+}
+
+content.addEventListener("load", loadListener);
+
+content.src = "http://mochi.test:8888/tests/editor/spellchecker/tests/multiple_content_languages_subframe.html?firstload=true";
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_nsIEditorSpellCheck_ReplaceWord.html b/editor/spellchecker/tests/test_nsIEditorSpellCheck_ReplaceWord.html
new file mode 100644
index 0000000000..a92c9f2f19
--- /dev/null
+++ b/editor/spellchecker/tests/test_nsIEditorSpellCheck_ReplaceWord.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for nsIEditorSpellCheck.ReplaceWord()</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<div contenteditable spellcheck="true" lang="en-US"></div>
+<script>
+"use strict";
+
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(async () => {
+ const { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ const editor = document.querySelector("div[contenteditable]");
+ async function replaceWord(aMisspelledWord, aCorrectWord, aReplaceAll) {
+ const editorObj = SpecialPowers.wrap(window).docShell.editingSession.getEditorForWindow(window);
+ const inlineSpellChecker = editorObj.getInlineSpellChecker(true);
+ await new Promise(resolve => maybeOnSpellCheck(editor, resolve));
+ const editorSpellCheck = inlineSpellChecker.spellChecker;
+ editorObj.beginTransaction();
+ try {
+ editorSpellCheck.ReplaceWord(aMisspelledWord, aCorrectWord, aReplaceAll);
+ } catch (e) {
+ ok(false, `Unexpected exception: ${e.message}`);
+ }
+ editorObj.endTransaction();
+ editorSpellCheck.GetNextMisspelledWord();
+ }
+
+ async function testReplaceAllMisspelledWords(aCorrectWord) {
+ editor.innerHTML = "<p>def abc def<br>abc def abc</p><p>abc def abc<br>def abc def</p>";
+ editor.focus();
+ editor.getBoundingClientRect();
+ await replaceWord("abc", aCorrectWord, true);
+ is(
+ editor.innerHTML,
+ `<p>def ${aCorrectWord} def<br>${aCorrectWord} def ${aCorrectWord}</p><p>${aCorrectWord} def ${aCorrectWord}<br>def ${aCorrectWord} def</p>`,
+ `nsIEditorSpellCheck.ReplaceWord(..., true) should replace all misspelled words with ${
+ (() => {
+ if (aCorrectWord.length > "abc".length) {
+ return "longer";
+ }
+ return aCorrectWord.length < "abc".length ? "shorter" : "same length"
+ })()
+ } correct word`
+ );
+ editor.blur();
+ editor.getBoundingClientRect();
+ }
+ await testReplaceAllMisspelledWords("ABC");
+ await testReplaceAllMisspelledWords("ABC!");
+ await testReplaceAllMisspelledWords("AB");
+
+ // TODO: Add tests for not all replacing cases.
+
+ SimpleTest.finish();
+});
+</script>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_spellcheck_after_edit.html b/editor/spellchecker/tests/test_spellcheck_after_edit.html
new file mode 100644
index 0000000000..24806addee
--- /dev/null
+++ b/editor/spellchecker/tests/test_spellcheck_after_edit.html
@@ -0,0 +1,194 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Spellcheck result after edit</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<script>
+let { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+function waitForTick() {
+ return new Promise(resolve =>
+ SimpleTest.executeSoon(
+ () => requestAnimationFrame(
+ () => requestAnimationFrame(resolve)
+ )
+ )
+ );
+}
+
+async function waitForOnSpellCheck(
+ aSpellCheckSelection,
+ aEditingHost,
+ aWaitForNumberOfMisspelledWords,
+ aWhen
+) {
+ info(`Waiting for onSpellCheck (${aWhen})...`);
+ for (let retry = 0; retry < 100; retry++) {
+ await waitForTick();
+ await new Promise(resolve => maybeOnSpellCheck(aEditingHost, resolve));
+ if (aWaitForNumberOfMisspelledWords === 0) {
+ if (aSpellCheckSelection.rangeCount === 0) {
+ break;
+ }
+ } else if (aSpellCheckSelection.rangeCount >= aWaitForNumberOfMisspelledWords) {
+ break;
+ }
+ }
+}
+
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(async () => {
+ /**
+ * test object should have:
+ * init function
+ * @param normalSel The normal selection for the editing host
+ * @param editingHost The editing host of the editor
+ * @return Number of misspelled word in the editor
+ *
+ * run function
+ * @param editingHost The editing host of the editor
+ * @return Expected number of misspelled word in the editor
+ *
+ * check function
+ * @param spellCheckSel The spellcheck selection for the editing host
+ * @param editingHost The editing host of the editor
+ */
+ for (const test of [
+ {
+ init: (normalSel, editingHost) => {
+ info("Staring to test spellcheck of misspelled word after joining paragraphs");
+ editingHost.innerHTML = "<p>It is</p><p>what I want</p>";
+ normalSel.collapse(editingHost.querySelector("p + p").firstChild, 0);
+ return 0;
+ },
+ run: (editingHost) => {
+ document.execCommand("delete");
+ return 0;
+ },
+ check: (spellCheckSel, editingHost) => {
+ is(
+ spellCheckSel.rangeCount,
+ 0,
+ "The joined misspelled word shouldn't be marked as misspelled word because caret is in the word"
+ );
+ },
+ },
+ {
+ init: (normalSel, editingHost) => {
+ info("Staring to test spellcheck of correct word after joining paragraphs");
+ editingHost.innerHTML = "<p>It's beco</p><p>ming nicer</p>";
+ normalSel.collapse(editingHost.querySelector("p + p").firstChild, 0);
+ return 2;
+ },
+ run: (editingHost) => {
+ document.execCommand("delete");
+ return 0;
+ },
+ check: (spellCheckSel, editingHost) => {
+ is(
+ spellCheckSel.rangeCount,
+ 0,
+ "There shouldn't be misspelled word after joining separated word anyway"
+ );
+ },
+ },
+ {
+ init: (normalSel, editingHost) => {
+ info("Staring to test spellcheck of correct words after splitting a paragraph");
+ editingHost.innerHTML = "<p>It iswhat I want</p>";
+ normalSel.collapse(editingHost.querySelector("p").firstChild, "It is".length);
+ return 1;
+ },
+ run: (editingHost) => {
+ document.execCommand("insertParagraph");
+ return 0;
+ },
+ check: (spellCheckSel, editingHost) => {
+ is(
+ spellCheckSel.rangeCount,
+ 0,
+ "No word should be marked as misspelled after split"
+ );
+ },
+ },
+ {
+ init: (normalSel, editingHost) => {
+ info("Staring to test spellcheck of misspelled words after splitting a paragraph");
+ editingHost.innerHTML = "<p>It's becoming nicer</p>";
+ normalSel.collapse(editingHost.querySelector("p").firstChild, "It's beco".length);
+ return 0;
+ },
+ run: (editingHost) => {
+ document.execCommand("insertParagraph");
+ return 1;
+ },
+ check: (spellCheckSel, editingHost) => {
+ is(
+ spellCheckSel.rangeCount,
+ 1,
+ "The split word in the first paragraph should be marked as misspelled, but the second paragraph's should be so because of caret is in it"
+ );
+ if (!spellCheckSel.rangeCount) {
+ return;
+ }
+ is(
+ SpecialPowers.unwrap(spellCheckSel.getRangeAt(0).startContainer),
+ editingHost.querySelector("p").firstChild,
+ "First misspelled word should start in the first child of the first <p>"
+ );
+ is(
+ SpecialPowers.unwrap(spellCheckSel.getRangeAt(0).endContainer),
+ editingHost.querySelector("p").firstChild,
+ "First misspelled word should end in the first child of the first <p>"
+ );
+ is(
+ spellCheckSel.getRangeAt(0).startOffset,
+ "It's ".length,
+ "First misspelled word should start after 'It '"
+ );
+ is(
+ spellCheckSel.getRangeAt(0).endOffset,
+ "It's beco".length,
+ "First misspelled word should end by after 'bec'"
+ );
+ },
+ },
+ ]) {
+ const editingHost = document.createElement("div");
+ editingHost.setAttribute("contenteditable", "");
+ editingHost.setAttribute("spellcheck", "true");
+ document.body.appendChild(editingHost);
+ editingHost.focus();
+ const editor =
+ SpecialPowers.wrap(window).docShell.editingSession.getEditorForWindow(window);
+ const nsISelectionController = SpecialPowers.Ci.nsISelectionController;
+ const normalSel = editor.selectionController.getSelection(
+ nsISelectionController.SELECTION_NORMAL
+ );
+ const spellCheckSel = editor.selectionController.getSelection(
+ nsISelectionController.SELECTION_SPELLCHECK
+ );
+ const initialMisspelledWords = test.init(normalSel, editingHost);
+ await waitForOnSpellCheck(
+ spellCheckSel, editingHost, initialMisspelledWords, "before edit"
+ );
+ await waitForTick();
+ const expectedMisspelledWords = test.run(editingHost);
+ await waitForOnSpellCheck(
+ spellCheckSel, editingHost, expectedMisspelledWords, "after edit"
+ );
+ test.check(spellCheckSel, editingHost);
+ editingHost.remove();
+ await waitForTick();
+ }
+ SimpleTest.finish();
+});
+</script>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_spellcheck_after_pressing_navigation_key.html b/editor/spellchecker/tests/test_spellcheck_after_pressing_navigation_key.html
new file mode 100644
index 0000000000..b1f36161ac
--- /dev/null
+++ b/editor/spellchecker/tests/test_spellcheck_after_pressing_navigation_key.html
@@ -0,0 +1,77 @@
+<!doctype html>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1729653
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1729653</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<textarea rows="20" cols="50">That undfgdfg seems OK.</textarea>
+<script>
+let { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+);
+
+function waitForTick() {
+ return new Promise(resolve => SimpleTest.executeSoon(resolve));
+}
+
+function waitForOnSpellCheck(aTextArea) {
+ info("Waiting for onSpellCheck...");
+ return new Promise(resolve => maybeOnSpellCheck(aTextArea, resolve));
+}
+
+/** Test for Bug 1729653 **/
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(async () => {
+ const textarea = document.querySelector("textarea");
+ textarea.focus();
+ textarea.selectionStart = textarea.selectionEnd = "That undfgdfg".length;
+ const editor = SpecialPowers.wrap(textarea).editor;
+ const nsISelectionController = SpecialPowers.Ci.nsISelectionController;
+ const selection = editor.selectionController.getSelection(nsISelectionController.SELECTION_SPELLCHECK);
+ const spellChecker = SpecialPowers.Cu.createSpellChecker();
+ spellChecker.InitSpellChecker(editor, false);
+ info("Waiting for current dictionary update...");
+ await new Promise(resolve => spellChecker.UpdateCurrentDictionary(resolve));
+ if (selection.rangeCount === 0) {
+ await waitForOnSpellCheck(textarea);
+ }
+ if (selection.rangeCount == 1) {
+ is(
+ selection.getRangeAt(0).toString(),
+ "undfgdfg",
+ "\"undfgdfg\" should be marked as misspelled word at start"
+ );
+ } else {
+ is(selection.rangeCount, 1, "We should have a misspelled word at start");
+ }
+ synthesizeKey(" ");
+ synthesizeKey("KEY_Backspace");
+ textarea.addEventListener("keydown", event => {
+ event.stopImmediatePropagation(); // This shouldn't block spellchecker to handle it.
+ }, {once: true});
+ synthesizeKey("KEY_End");
+ await waitForTick();
+ if (selection.rangeCount === 0) {
+ await waitForOnSpellCheck(textarea);
+ }
+ if (selection.rangeCount == 1) {
+ is(
+ selection.getRangeAt(0).toString(),
+ "undfgdfg",
+ "\"undfgdfg\" should be marked as misspelled word at end"
+ );
+ } else {
+ is(selection.rangeCount, 1, "We should have a misspelled word at end");
+ }
+ SimpleTest.finish();
+});
+</script>
+</body>
+</html>
diff --git a/editor/spellchecker/tests/test_spellcheck_selection.html b/editor/spellchecker/tests/test_spellcheck_selection.html
new file mode 100644
index 0000000000..983f67c51f
--- /dev/null
+++ b/editor/spellchecker/tests/test_spellcheck_selection.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>Bug 1779846: Test enableSelectionChecking=true on nsIEditorSpellCheck.InitSpellChecker</title>
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+
+<div contenteditable lang="en-US">missspelled</div>
+
+<script>
+add_task(async function() {
+ await new Promise(resolve => SimpleTest.waitForFocus(resolve));
+
+ let { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+
+ let editingHost = document.querySelector("div[contenteditable][lang=en-US]");
+ editingHost.focus();
+
+ await new Promise(resolve => maybeOnSpellCheck(editingHost, resolve));
+
+ let editingSession = SpecialPowers.wrap(window).docShell.editingSession;
+ let editor = editingSession.getEditorForWindow(window);
+ let spellchecker = SpecialPowers.Cu.createSpellChecker();
+ spellchecker.setFilterType(spellchecker.FILTERTYPE_NORMAL);
+
+ /* Select "missspelled" in the <div>. */
+ window.getSelection().selectAllChildren(editingHost);
+
+ /* Pass true to InitSpellChecker to spellcheck the current selection of the editor.*/
+ await new Promise(resolve => spellchecker.InitSpellChecker(editor, true, resolve));
+
+ /* InitSpellChecker with enableSelectionChecking=true shouldn't throw any errors. */
+ ok(spellchecker.canSpellCheck());
+});
+</script>
diff --git a/editor/spellchecker/tests/test_suggest.html b/editor/spellchecker/tests/test_suggest.html
new file mode 100644
index 0000000000..b3e35458c7
--- /dev/null
+++ b/editor/spellchecker/tests/test_suggest.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for nsIEditorSpellChecfker.sugget</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<div contenteditable id="en-US" lang="en-US">missspelled</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+add_task(async function() {
+ await new Promise(resolve => SimpleTest.waitForFocus(resolve));
+
+ let { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+
+ let element = document.getElementById("en-US");
+ element.focus();
+
+ await new Promise(resolve => maybeOnSpellCheck(element, resolve));
+
+ let editingSession = SpecialPowers.wrap(window).docShell.editingSession;
+ let editor = editingSession.getEditorForWindow(window);
+ let spellchecker = SpecialPowers.Cu.createSpellChecker();
+ spellchecker.setFilterType(spellchecker.FILTERTYPE_NORMAL);
+ await new Promise(resolve => spellchecker.InitSpellChecker(editor, false, resolve));
+
+ let suggestions = await spellchecker.suggest("misspelled", 5);
+ is(suggestions.length, 0, "\"misspelled\" is correct word");
+
+ suggestions = await spellchecker.suggest("missspelled", 5);
+ is(suggestions.length, 5, "\"missspelled\" isn't correct word");
+});
+</script>
+</pre>
+</body>
+</html>