diff options
Diffstat (limited to 'editor/spellchecker')
59 files changed, 8511 insertions, 0 deletions
diff --git a/editor/spellchecker/EditorSpellCheck.cpp b/editor/spellchecker/EditorSpellCheck.cpp new file mode 100644 index 0000000000..35b4da06cf --- /dev/null +++ b/editor/spellchecker/EditorSpellCheck.cpp @@ -0,0 +1,1179 @@ +/* -*- 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; + nsString mRootContentLang; + nsString 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(TaskCategory::Other, 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); + rootEditableElement->GetLang(fetcher->mRootContentLang); + RefPtr<Document> doc = rootEditableElement->GetComposedDoc(); + NS_ENSURE_STATE(doc); + doc->GetContentLanguage(fetcher->mRootDocContentLang); + + 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(); + CopyUTF16toUTF8(aFetcher->mRootContentLang, 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. + CopyUTF16toUTF8(aFetcher->mRootDocContentLang, 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..864cc57c2a --- /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); + + ContentIteratorBase* 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..c348c70ad6 --- /dev/null +++ b/editor/spellchecker/TextServicesDocument.cpp @@ -0,0 +1,2809 @@ +/* -*- 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 "HTMLEditUtils.h" // for HTMLEditUtils +#include "JoinSplitNodeDirection.h" // for JoinNodesDirection + +#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, + JoinNodesDirection aJoinNodesDirection) { + // 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 (aJoinNodesDirection == JoinNodesDirection::LeftNodeIntoRightNode) { + if (MOZ_UNLIKELY(removedIndex > joinedIndex)) { + NS_ASSERTION(removedIndex < joinedIndex, "Indexes out of order."); + return; + } + NS_ASSERTION(mOffsetTable[joinedIndex]->mOffsetInTextNode == 0, + "Unexpected offset value for joinedIndex."); + } else { + 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 = + aJoinNodesDirection == JoinNodesDirection::LeftNodeIntoRightNode + ? aJoinedPoint.Offset() + : 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>(); + if (aJoinNodesDirection == JoinNodesDirection::RightNodeIntoLeftNode) { + // The text was moved from aRemovedContent to end of the container of + // aJoinedPoint. + entry->mOffsetInTextNode += movedTextDataLength; + } + } + } + + if (aJoinNodesDirection == JoinNodesDirection::LeftNodeIntoRightNode) { + // The text was moved from aRemovedContent to start of the container of + // aJoinedPoint. + for (uint32_t i = joinedIndex; i < mOffsetTable.Length(); i++) { + const UniquePtr<OffsetEntry>& entry = mOffsetTable[i]; + LockOffsetEntryArrayLengthInDebugBuild(observer, mOffsetTable); + if (entry->mTextNode != aJoinedPoint.ContainerAs<Text>()) { + break; + } + if (entry->mIsValid) { + 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); + const Element* editableBlockElementOrInlineEditingHost2 = + HTMLEditUtils::GetAncestorElement( + aTextNode2, + HTMLEditUtils::ClosestEditableBlockElementOrInlineEditingHost); + 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; + if (lastTextNode && content && + (HTMLEditUtils::IsBlockElement(*content) || + 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(); + } else if (!crossedBlockBoundary && + (HTMLEditUtils::IsBlockElement(*content) || + 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) { + if (HTMLEditUtils::IsBlockElement(*content) || + 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(); + uint32_t strLen = aAllTextInBlock.Length(); + MOZ_ASSERT(strOffset <= strLen, + "The string offset shouldn't be greater than the string length!"); + + intl::WordRange res = intl::WordBreaker::FindWord(str, strLen, 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, + bool aLeftNodeWasRemoved) { + if (MOZ_UNLIKELY(NS_WARN_IF(!aJoinedPoint.IsSetAndValid()) || + NS_WARN_IF(!aRemovedNode->IsContent()))) { + return NS_OK; + } + DidJoinContents(aJoinedPoint, *aRemovedNode->AsContent(), + aLeftNodeWasRemoved + ? JoinNodesDirection::LeftNodeIntoRightNode + : JoinNodesDirection::RightNodeIntoLeftNode); + 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..7bc6aad7d5 --- /dev/null +++ b/editor/spellchecker/TextServicesDocument.h @@ -0,0 +1,438 @@ +/* -*- 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; +enum class JoinNodesDirection; // Declared in HTMLEditHelpers.h + +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, + JoinNodesDirection aJoinNodesDirection); + + 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..8e4932c209 --- /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.ini"] + +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome.ini"] + +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.ini b/editor/spellchecker/tests/chrome.ini new file mode 100644 index 0000000000..76faf04121 --- /dev/null +++ b/editor/spellchecker/tests/chrome.ini @@ -0,0 +1,9 @@ +[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.ini b/editor/spellchecker/tests/mochitest.ini new file mode 100644 index 0000000000..6d3c53d71b --- /dev/null +++ b/editor/spellchecker/tests/mochitest.ini @@ -0,0 +1,68 @@ +[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_bug1100966.html] +[test_bug1154791.html] +[test_bug1200533.html] +skip-if = + http3 +support-files = + bug1200533_subframe.html +[test_bug1204147.html] +skip-if = + http3 +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_bug366682.html] +[test_bug432225.html] +[test_bug484181.html] +[test_bug596333.html] +[test_bug636465.html] +[test_bug678842.html] +skip-if = + http3 +support-files = + bug678842_subframe.html +[test_bug697981.html] +[test_bug717433.html] +skip-if = + http3 +support-files = + bug717433_subframe.html +[test_multiple_content_languages.html] +skip-if = + http3 +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..6f8371d2cd --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ) + 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..4ed52968a0 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +/** 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..7ed6c53e35 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +/** 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..d97dea8ccd --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..3d5f0e120f --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..3de5e196bf --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +/** 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..b4b0cae947 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +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..83ae8de908 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + + 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..d4333b4eb0 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..4a182615a8 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..6380060001 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +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..442663f0c2 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +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..a7063271f4 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +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..e8c2886d9d --- /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ökkkk</div> + +<script> +const { maybeOnSpellCheck } = SpecialPowers.ChromeUtils.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +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..543e56e810 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +/** 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..e31c819761 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +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..dd869ffc11 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..ac38bcf9d2 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..2168429613 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +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..30f0b359c8 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..ce6714565b --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ).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..97252d92df --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..f5f190ab0f --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..044f82048b --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +/** 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..2f09cb70fd --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..c3357dec91 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..6088b97e3e --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + 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..e4fa76d2e4 --- /dev/null +++ b/editor/spellchecker/tests/test_spellcheck_after_edit.html @@ -0,0 +1,198 @@ +<!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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +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"); + // eslint-disable-next-line no-unsanitized/property + 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"); + // eslint-disable-next-line no-unsanitized/property + 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"); + // eslint-disable-next-line no-unsanitized/property + 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"); + // eslint-disable-next-line no-unsanitized/property + 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..2f0c3bed7d --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" +); + +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..0d0887a8f3 --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + + 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..2bdee93c1d --- /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.import( + "resource://testing-common/AsyncSpellCheckTestHelper.jsm" + ); + + 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> |