diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /extensions/spellcheck/src | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'extensions/spellcheck/src')
-rw-r--r-- | extensions/spellcheck/src/components.conf | 20 | ||||
-rw-r--r-- | extensions/spellcheck/src/moz.build | 30 | ||||
-rw-r--r-- | extensions/spellcheck/src/mozEnglishWordUtils.cpp | 102 | ||||
-rw-r--r-- | extensions/spellcheck/src/mozEnglishWordUtils.h | 40 | ||||
-rw-r--r-- | extensions/spellcheck/src/mozInlineSpellChecker.cpp | 2120 | ||||
-rw-r--r-- | extensions/spellcheck/src/mozInlineSpellChecker.h | 338 | ||||
-rw-r--r-- | extensions/spellcheck/src/mozInlineSpellWordUtil.cpp | 1174 | ||||
-rw-r--r-- | extensions/spellcheck/src/mozInlineSpellWordUtil.h | 253 | ||||
-rw-r--r-- | extensions/spellcheck/src/mozPersonalDictionary.cpp | 433 | ||||
-rw-r--r-- | extensions/spellcheck/src/mozPersonalDictionary.h | 80 | ||||
-rw-r--r-- | extensions/spellcheck/src/mozSpellChecker.cpp | 681 | ||||
-rw-r--r-- | extensions/spellcheck/src/mozSpellChecker.h | 197 |
12 files changed, 5468 insertions, 0 deletions
diff --git a/extensions/spellcheck/src/components.conf b/extensions/spellcheck/src/components.conf new file mode 100644 index 0000000000..7fc73c1a17 --- /dev/null +++ b/extensions/spellcheck/src/components.conf @@ -0,0 +1,20 @@ +# -*- 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/. + +Classes = [ + { + 'cid': '{56c778e4-1bee-45f3-a689-886692a97fe7}', + 'contract_ids': ['@mozilla.org/spellchecker/engine;1'], + 'type': 'mozHunspell', + }, + { + 'cid': '{7ef52eaf-b7e1-462b-87e2-5d1dbaca9048}', + 'contract_ids': ['@mozilla.org/spellchecker/personaldictionary;1'], + 'type': 'mozPersonalDictionary', + 'headers': ['/extensions/spellcheck/src/mozPersonalDictionary.h'], + 'init_method': 'Init', + }, +] diff --git a/extensions/spellcheck/src/moz.build b/extensions/spellcheck/src/moz.build new file mode 100644 index 0000000000..7ba7826a1e --- /dev/null +++ b/extensions/spellcheck/src/moz.build @@ -0,0 +1,30 @@ +# -*- 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/. + +include("/ipc/chromium/chromium-config.mozbuild") +UNIFIED_SOURCES += [ + "mozEnglishWordUtils.cpp", + "mozInlineSpellChecker.cpp", + "mozInlineSpellWordUtil.cpp", + "mozPersonalDictionary.cpp", + "mozSpellChecker.cpp", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +FINAL_LIBRARY = "xul" + +LOCAL_INCLUDES += [ + "../hunspell/glue", + "../hunspell/src", + "/dom/base", +] +EXPORTS.mozilla += [ + "mozInlineSpellChecker.h", + "mozSpellChecker.h", +] diff --git a/extensions/spellcheck/src/mozEnglishWordUtils.cpp b/extensions/spellcheck/src/mozEnglishWordUtils.cpp new file mode 100644 index 0000000000..f3ae8a0a73 --- /dev/null +++ b/extensions/spellcheck/src/mozEnglishWordUtils.cpp @@ -0,0 +1,102 @@ +/* -*- 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 "mozEnglishWordUtils.h" +#include "nsComponentManagerUtils.h" +#include "nsReadableUtils.h" +#include "nsUnicharUtils.h" +#include "nsUnicodeProperties.h" +#include "nsCRT.h" +#include "mozilla/Likely.h" + +NS_IMPL_CYCLE_COLLECTION(mozEnglishWordUtils, mURLDetector) + +mozEnglishWordUtils::mozEnglishWordUtils() { + mURLDetector = do_CreateInstance(MOZ_TXTTOHTMLCONV_CONTRACTID); +} + +mozEnglishWordUtils::~mozEnglishWordUtils() {} + +// This needs vast improvement + +// static +bool mozEnglishWordUtils::ucIsAlpha(char16_t aChar) { + // XXX we have to fix callers to handle the full Unicode range + return nsUGenCategory::kLetter == mozilla::unicode::GetGenCategory(aChar); +} + +bool mozEnglishWordUtils::FindNextWord(const nsAString& aWord, uint32_t offset, + int32_t* begin, int32_t* end) { + if (offset >= aWord.Length()) { + *begin = -1; + *end = -1; + return false; + } + + const char16_t* word = aWord.BeginReading(); + uint32_t length = aWord.Length(); + const char16_t* p = word + offset; + const char16_t* endbuf = word + length; + const char16_t* startWord = p; + + // XXX These loops should be modified to handle non-BMP characters. + // if previous character is a word character, need to advance out of the + // word + if (offset > 0 && ucIsAlpha(*(p - 1))) { + while (p < endbuf && ucIsAlpha(*p)) { + p++; + } + } + while ((p < endbuf) && (!ucIsAlpha(*p))) { + p++; + } + startWord = p; + while ((p < endbuf) && ((ucIsAlpha(*p)) || (*p == '\''))) { + p++; + } + + // we could be trying to break down a url, we don't want to break a url into + // parts, instead we want to find out if it really is a url and if so, skip + // it, advancing startWord to a point after the url. + + // before we spend more time looking to see if the word is a url, look for a + // url identifer and make sure that identifer isn't the last character in + // the word fragment. + if ((p < endbuf - 1) && (*p == ':' || *p == '@' || *p == '.')) { + // ok, we have a possible url...do more research to find out if we really + // have one and determine the length of the url so we can skip over it. + + if (mURLDetector) { + int32_t startPos = -1; + int32_t endPos = -1; + + mURLDetector->FindURLInPlaintext(startWord, endbuf - startWord, + p - startWord, &startPos, &endPos); + + // ok, if we got a url, adjust the array bounds, skip the current url + // text and find the next word again + if (startPos != -1 && endPos != -1) { + startWord = p + endPos + 1; // skip over the url + + // now recursively call FindNextWord to search for the next word now + // that we have skipped the url + return FindNextWord(aWord, startWord - word, begin, end); + } + } + } + + while ((p > startWord) && (*(p - 1) == '\'')) { // trim trailing apostrophes + p--; + } + + if (startWord == endbuf) { + *begin = -1; + *end = -1; + return false; + } + *begin = startWord - word; + *end = p - word; + return true; +} diff --git a/extensions/spellcheck/src/mozEnglishWordUtils.h b/extensions/spellcheck/src/mozEnglishWordUtils.h new file mode 100644 index 0000000000..64338fe2c6 --- /dev/null +++ b/extensions/spellcheck/src/mozEnglishWordUtils.h @@ -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/. */ + +#ifndef mozEnglishWordUtils_h__ +#define mozEnglishWordUtils_h__ + +#include "nsCOMPtr.h" +#include "nsString.h" + +#include "mozITXTToHTMLConv.h" +#include "nsCycleCollectionParticipant.h" + +class mozEnglishWordUtils final { + public: + NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(mozEnglishWordUtils) + NS_DECL_CYCLE_COLLECTION_NATIVE_CLASS(mozEnglishWordUtils) + + mozEnglishWordUtils(); + + /** + * Given a unicode string and an offset, find the beginning and end of the + * next word. Return false, begin and end are -1 if there are no words + * remaining in the string. This should really be folded into the + * Line/WordBreaker. + */ + bool FindNextWord(const nsAString& aWord, uint32_t offset, int32_t* begin, + int32_t* end); + + protected: + virtual ~mozEnglishWordUtils(); + + static bool ucIsAlpha(char16_t aChar); + + nsCOMPtr<mozITXTToHTMLConv> + mURLDetector; // used to detect urls so the spell checker can skip them. +}; + +#endif diff --git a/extensions/spellcheck/src/mozInlineSpellChecker.cpp b/extensions/spellcheck/src/mozInlineSpellChecker.cpp new file mode 100644 index 0000000000..d144fff317 --- /dev/null +++ b/extensions/spellcheck/src/mozInlineSpellChecker.cpp @@ -0,0 +1,2120 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set sw=2 ts=2 sts=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/. */ + +/** + * This class is called by the editor to handle spellchecking after various + * events. The main entrypoint is SpellCheckAfterEditorChange, which is called + * when the text is changed. + * + * It is VERY IMPORTANT that we do NOT do any operations that might cause DOM + * notifications to be flushed when we are called from the editor. This is + * because the call might originate from a frame, and flushing the + * notifications might cause that frame to be deleted. + * + * We post an event and do all of the spellchecking in that event handler. + * We store all DOM pointers in ranges because they are kept up-to-date with + * DOM changes that may have happened while the event was on the queue. + * + * We also allow the spellcheck to be suspended and resumed later. This makes + * large pastes or initializations with a lot of text not hang the browser UI. + * + * An optimization is the mNeedsCheckAfterNavigation flag. This is set to + * true when we get any change, and false once there is no possibility + * something changed that we need to check on navigation. Navigation events + * tend to be a little tricky because we want to check the current word on + * exit if something has changed. If we navigate inside the word, we don't want + * to do anything. As a result, this flag is cleared in FinishNavigationEvent + * when we know that we are checking as a result of navigation. + */ + +#include "mozInlineSpellChecker.h" + +#include "mozilla/Assertions.h" +#include "mozilla/Attributes.h" +#include "mozilla/EditAction.h" +#include "mozilla/EditorBase.h" +#include "mozilla/EditorDOMPoint.h" +#include "mozilla/EditorSpellCheck.h" +#include "mozilla/EventListenerManager.h" +#include "mozilla/HTMLEditor.h" +#include "mozilla/IntegerRange.h" +#include "mozilla/Logging.h" +#include "mozilla/RangeUtils.h" +#include "mozilla/Services.h" +#include "mozilla/StaticPrefs_extensions.h" +#include "mozilla/TextEvents.h" +#include "mozilla/dom/Event.h" +#include "mozilla/dom/KeyboardEvent.h" +#include "mozilla/dom/KeyboardEventBinding.h" +#include "mozilla/dom/MouseEvent.h" +#include "mozilla/dom/Selection.h" +#include "mozInlineSpellWordUtil.h" +#ifdef ACCESSIBILITY +# include "nsAccessibilityService.h" +#endif +#include "nsCOMPtr.h" +#include "nsCRT.h" +#include "nsGenericHTMLElement.h" +#include "nsRange.h" +#include "nsIPrefBranch.h" +#include "nsIPrefService.h" +#include "nsIRunnable.h" +#include "nsServiceManagerUtils.h" +#include "nsString.h" +#include "nsThreadUtils.h" +#include "nsUnicharUtils.h" +#include "nsIContent.h" +#include "nsIContentInlines.h" +#include "nsRange.h" +#include "nsContentUtils.h" +#include "nsIObserverService.h" +#include "prtime.h" + +using mozilla::LogLevel; +using namespace mozilla; +using namespace mozilla::dom; +using namespace mozilla::ipc; + +// the number of milliseconds that we will take at once to do spellchecking +#define INLINESPELL_CHECK_TIMEOUT 1 + +// The number of words to check before we look at the time to see if +// INLINESPELL_CHECK_TIMEOUT ms have elapsed. This prevents us from getting +// stuck and not moving forward because the INLINESPELL_CHECK_TIMEOUT might +// be too short to a low-end machine. +#define INLINESPELL_MINIMUM_WORDS_BEFORE_TIMEOUT 5 + +// The maximum number of words to check word via IPC. +#define INLINESPELL_MAXIMUM_CHUNKED_WORDS_PER_TASK 25 + +// These notifications are broadcast when spell check starts and ends. STARTED +// must always be followed by ENDED. +#define INLINESPELL_STARTED_TOPIC "inlineSpellChecker-spellCheck-started" +#define INLINESPELL_ENDED_TOPIC "inlineSpellChecker-spellCheck-ended" + +static mozilla::LazyLogModule sInlineSpellCheckerLog("InlineSpellChecker"); + +static const PRTime kMaxSpellCheckTimeInUsec = + INLINESPELL_CHECK_TIMEOUT * PR_USEC_PER_MSEC; + +mozInlineSpellStatus::mozInlineSpellStatus( + mozInlineSpellChecker* aSpellChecker, const Operation aOp, + RefPtr<nsRange>&& aRange, RefPtr<nsRange>&& aCreatedRange, + RefPtr<nsRange>&& aAnchorRange, const bool aForceNavigationWordCheck, + const int32_t aNewNavigationPositionOffset) + : mSpellChecker(aSpellChecker), + mRange(std::move(aRange)), + mOp(aOp), + mCreatedRange(std::move(aCreatedRange)), + mAnchorRange(std::move(aAnchorRange)), + mForceNavigationWordCheck(aForceNavigationWordCheck), + mNewNavigationPositionOffset(aNewNavigationPositionOffset) {} + +// mozInlineSpellStatus::CreateForEditorChange +// +// This is the most complicated case. For changes, we need to compute the +// range of stuff that changed based on the old and new caret positions, +// as well as use a range possibly provided by the editor (start and end, +// which are usually nullptr) to get a range with the union of these. + +// static +Result<UniquePtr<mozInlineSpellStatus>, nsresult> +mozInlineSpellStatus::CreateForEditorChange( + mozInlineSpellChecker& aSpellChecker, const EditSubAction aEditSubAction, + nsINode* aAnchorNode, uint32_t aAnchorOffset, nsINode* aPreviousNode, + uint32_t aPreviousOffset, nsINode* aStartNode, uint32_t aStartOffset, + nsINode* aEndNode, uint32_t aEndOffset) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Verbose, ("%s", __FUNCTION__)); + + if (NS_WARN_IF(!aAnchorNode) || NS_WARN_IF(!aPreviousNode)) { + return Err(NS_ERROR_FAILURE); + } + + bool deleted = aEditSubAction == EditSubAction::eDeleteSelectedContent; + if (aEditSubAction == EditSubAction::eInsertTextComingFromIME) { + // IME may remove the previous node if it cancels composition when + // there is no text around the composition. + deleted = !aPreviousNode->IsInComposedDoc(); + } + + // save the anchor point as a range so we can find the current word later + RefPtr<nsRange> anchorRange = mozInlineSpellStatus::PositionToCollapsedRange( + aAnchorNode, aAnchorOffset); + if (NS_WARN_IF(!anchorRange)) { + return Err(NS_ERROR_FAILURE); + } + + // Deletes are easy, the range is just the current anchor. We set the range + // to check to be empty, FinishInitOnEvent will fill in the range to be + // the current word. + RefPtr<nsRange> range = deleted ? nullptr : nsRange::Create(aPreviousNode); + + // On insert save this range: DoSpellCheck optimizes things in this range. + // Otherwise, just leave this nullptr. + RefPtr<nsRange> createdRange = + (aEditSubAction == EditSubAction::eInsertText) ? range : nullptr; + + UniquePtr<mozInlineSpellStatus> status{ + /* The constructor is `private`, hence the explicit allocation. */ + new mozInlineSpellStatus{&aSpellChecker, + deleted ? eOpChangeDelete : eOpChange, + std::move(range), std::move(createdRange), + std::move(anchorRange), false, 0}}; + if (deleted) { + return status; + } + + // ...we need to put the start and end in the correct order + ErrorResult errorResult; + int16_t cmpResult = status->mAnchorRange->ComparePoint( + *aPreviousNode, aPreviousOffset, errorResult); + if (NS_WARN_IF(errorResult.Failed())) { + return Err(errorResult.StealNSResult()); + } + nsresult rv; + if (cmpResult < 0) { + // previous anchor node is before the current anchor + rv = status->mRange->SetStartAndEnd(aPreviousNode, aPreviousOffset, + aAnchorNode, aAnchorOffset); + if (NS_WARN_IF(NS_FAILED(rv))) { + return Err(rv); + } + } else { + // previous anchor node is after (or the same as) the current anchor + rv = status->mRange->SetStartAndEnd(aAnchorNode, aAnchorOffset, + aPreviousNode, aPreviousOffset); + if (NS_WARN_IF(NS_FAILED(rv))) { + return Err(rv); + } + } + + // if we were given a range, we need to expand our range to encompass it + if (aStartNode && aEndNode) { + cmpResult = + status->mRange->ComparePoint(*aStartNode, aStartOffset, errorResult); + if (NS_WARN_IF(errorResult.Failed())) { + return Err(errorResult.StealNSResult()); + } + if (cmpResult < 0) { // given range starts before + rv = status->mRange->SetStart(aStartNode, aStartOffset); + if (NS_WARN_IF(NS_FAILED(rv))) { + return Err(rv); + } + } + + cmpResult = + status->mRange->ComparePoint(*aEndNode, aEndOffset, errorResult); + if (NS_WARN_IF(errorResult.Failed())) { + return Err(errorResult.StealNSResult()); + } + if (cmpResult > 0) { // given range ends after + rv = status->mRange->SetEnd(aEndNode, aEndOffset); + if (NS_WARN_IF(NS_FAILED(rv))) { + return Err(rv); + } + } + } + + return status; +} + +// mozInlineSpellStatus::CreateForNavigation +// +// For navigation events, we just need to store the new and old positions. +// +// In some cases, we detect that we shouldn't check. If this event should +// not be processed, *aContinue will be false. + +// static +Result<UniquePtr<mozInlineSpellStatus>, nsresult> +mozInlineSpellStatus::CreateForNavigation( + mozInlineSpellChecker& aSpellChecker, bool aForceCheck, + int32_t aNewPositionOffset, nsINode* aOldAnchorNode, + uint32_t aOldAnchorOffset, nsINode* aNewAnchorNode, + uint32_t aNewAnchorOffset, bool* aContinue) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Verbose, ("%s", __FUNCTION__)); + + RefPtr<nsRange> anchorRange = mozInlineSpellStatus::PositionToCollapsedRange( + aNewAnchorNode, aNewAnchorOffset); + if (NS_WARN_IF(!anchorRange)) { + return Err(NS_ERROR_FAILURE); + } + + UniquePtr<mozInlineSpellStatus> status{ + /* The constructor is `private`, hence the explicit allocation. */ + new mozInlineSpellStatus{&aSpellChecker, eOpNavigation, nullptr, nullptr, + std::move(anchorRange), aForceCheck, + aNewPositionOffset}}; + + // get the root node for checking + EditorBase* editorBase = status->mSpellChecker->mEditorBase; + if (NS_WARN_IF(!editorBase)) { + return Err(NS_ERROR_FAILURE); + } + Element* root = editorBase->GetRoot(); + if (NS_WARN_IF(!root)) { + return Err(NS_ERROR_FAILURE); + } + // the anchor node might not be in the DOM anymore, check + if (root && aOldAnchorNode && + !aOldAnchorNode->IsShadowIncludingInclusiveDescendantOf(root)) { + *aContinue = false; + return status; + } + + status->mOldNavigationAnchorRange = + mozInlineSpellStatus::PositionToCollapsedRange(aOldAnchorNode, + aOldAnchorOffset); + if (NS_WARN_IF(!status->mOldNavigationAnchorRange)) { + return Err(NS_ERROR_FAILURE); + } + + *aContinue = true; + return status; +} + +// mozInlineSpellStatus::CreateForSelection +// +// It is easy for selections since we always re-check the spellcheck +// selection. + +// static +UniquePtr<mozInlineSpellStatus> mozInlineSpellStatus::CreateForSelection( + mozInlineSpellChecker& aSpellChecker) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Verbose, ("%s", __FUNCTION__)); + + UniquePtr<mozInlineSpellStatus> status{ + /* The constructor is `private`, hence the explicit allocation. */ + new mozInlineSpellStatus{&aSpellChecker, eOpSelection, nullptr, nullptr, + nullptr, false, 0}}; + return status; +} + +// mozInlineSpellStatus::CreateForRange +// +// Called to cause the spellcheck of the given range. This will look like +// a change operation over the given range. + +// static +UniquePtr<mozInlineSpellStatus> mozInlineSpellStatus::CreateForRange( + mozInlineSpellChecker& aSpellChecker, nsRange* aRange) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Debug, + ("%s: range=%p", __FUNCTION__, aRange)); + + UniquePtr<mozInlineSpellStatus> status{ + /* The constructor is `private`, hence the explicit allocation. */ + new mozInlineSpellStatus{&aSpellChecker, eOpChange, nullptr, nullptr, + nullptr, false, 0}}; + + status->mRange = aRange; + return status; +} + +// mozInlineSpellStatus::FinishInitOnEvent +// +// Called when the event is triggered to complete initialization that +// might require the WordUtil. This calls to the operation-specific +// initializer, and also sets the range to be the entire element if it +// is nullptr. +// +// Watch out: the range might still be nullptr if there is nothing to do, +// the caller will have to check for this. + +nsresult mozInlineSpellStatus::FinishInitOnEvent( + mozInlineSpellWordUtil& aWordUtil) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Verbose, + ("%s: mRange=%p", __FUNCTION__, mRange.get())); + + nsresult rv; + if (!mRange) { + rv = mSpellChecker->MakeSpellCheckRange(nullptr, 0, nullptr, 0, + getter_AddRefs(mRange)); + NS_ENSURE_SUCCESS(rv, rv); + } + + switch (mOp) { + case eOpChange: + if (mAnchorRange) return FillNoCheckRangeFromAnchor(aWordUtil); + break; + case eOpChangeDelete: + if (mAnchorRange) { + rv = FillNoCheckRangeFromAnchor(aWordUtil); + NS_ENSURE_SUCCESS(rv, rv); + } + // Delete events will have no range for the changed text (because it was + // deleted), and CreateForEditorChange will set it to nullptr. Here, we + // select the entire word to cause any underlining to be removed. + mRange = mNoCheckRange; + break; + case eOpNavigation: + return FinishNavigationEvent(aWordUtil); + case eOpSelection: + // this gets special handling in ResumeCheck + break; + case eOpResume: + // everything should be initialized already in this case + break; + default: + MOZ_ASSERT_UNREACHABLE("Bad operation"); + return NS_ERROR_NOT_INITIALIZED; + } + return NS_OK; +} + +// mozInlineSpellStatus::FinishNavigationEvent +// +// This verifies that we need to check the word at the previous caret +// position. Now that we have the word util, we can find the word belonging +// to the previous caret position. If the new position is inside that word, +// we don't want to do anything. In this case, we'll nullptr out mRange so +// that the caller will know not to continue. +// +// Notice that we don't set mNoCheckRange. We check here whether the cursor +// is in the word that needs checking, so it isn't necessary. Plus, the +// spellchecker isn't guaranteed to only check the given word, and it could +// remove the underline from the new word under the cursor. + +nsresult mozInlineSpellStatus::FinishNavigationEvent( + mozInlineSpellWordUtil& aWordUtil) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Verbose, ("%s", __FUNCTION__)); + + RefPtr<EditorBase> editorBase = mSpellChecker->mEditorBase; + if (!editorBase) { + return NS_ERROR_FAILURE; // editor is gone + } + + MOZ_ASSERT(mAnchorRange, "No anchor for navigation!"); + + if (!mOldNavigationAnchorRange->IsPositioned()) { + return NS_ERROR_NOT_INITIALIZED; + } + + // get the DOM position of the old caret, the range should be collapsed + nsCOMPtr<nsINode> oldAnchorNode = + mOldNavigationAnchorRange->GetStartContainer(); + uint32_t oldAnchorOffset = mOldNavigationAnchorRange->StartOffset(); + + // find the word on the old caret position, this is the one that we MAY need + // to check + RefPtr<nsRange> oldWord; + nsresult rv = aWordUtil.GetRangeForWord(oldAnchorNode, + static_cast<int32_t>(oldAnchorOffset), + getter_AddRefs(oldWord)); + NS_ENSURE_SUCCESS(rv, rv); + + // aWordUtil.GetRangeForWord flushes pending notifications, check editor + // again. + if (!mSpellChecker->mEditorBase) { + return NS_ERROR_FAILURE; // editor is gone + } + + // get the DOM position of the new caret, the range should be collapsed + nsCOMPtr<nsINode> newAnchorNode = mAnchorRange->GetStartContainer(); + uint32_t newAnchorOffset = mAnchorRange->StartOffset(); + + // see if the new cursor position is in the word of the old cursor position + bool isInRange = false; + if (!mForceNavigationWordCheck) { + ErrorResult err; + isInRange = oldWord->IsPointInRange( + *newAnchorNode, newAnchorOffset + mNewNavigationPositionOffset, err); + if (NS_WARN_IF(err.Failed())) { + return err.StealNSResult(); + } + } + + if (isInRange) { + // caller should give up + mRange = nullptr; + } else { + // check the old word + mRange = oldWord; + + // Once we've spellchecked the current word, we don't need to spellcheck + // for any more navigation events. + mSpellChecker->mNeedsCheckAfterNavigation = false; + } + return NS_OK; +} + +// mozInlineSpellStatus::FillNoCheckRangeFromAnchor +// +// Given the mAnchorRange object, computes the range of the word it is on +// (if any) and fills that range into mNoCheckRange. This is used for +// change and navigation events to know which word we should skip spell +// checking on + +nsresult mozInlineSpellStatus::FillNoCheckRangeFromAnchor( + mozInlineSpellWordUtil& aWordUtil) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Verbose, ("%s", __FUNCTION__)); + + if (!mAnchorRange->IsPositioned()) { + return NS_ERROR_NOT_INITIALIZED; + } + nsCOMPtr<nsINode> anchorNode = mAnchorRange->GetStartContainer(); + uint32_t anchorOffset = mAnchorRange->StartOffset(); + return aWordUtil.GetRangeForWord(anchorNode, + static_cast<int32_t>(anchorOffset), + getter_AddRefs(mNoCheckRange)); +} + +// mozInlineSpellStatus::GetDocument +// +// Returns the Document object for the document for the +// current spellchecker. + +Document* mozInlineSpellStatus::GetDocument() const { + if (!mSpellChecker->mEditorBase) { + return nullptr; + } + + return mSpellChecker->mEditorBase->GetDocument(); +} + +// mozInlineSpellStatus::PositionToCollapsedRange +// +// Converts a given DOM position to a collapsed range covering that +// position. We use ranges to store DOM positions becuase they stay +// updated as the DOM is changed. + +// static +already_AddRefed<nsRange> mozInlineSpellStatus::PositionToCollapsedRange( + nsINode* aNode, uint32_t aOffset) { + if (NS_WARN_IF(!aNode)) { + return nullptr; + } + IgnoredErrorResult ignoredError; + RefPtr<nsRange> range = + nsRange::Create(aNode, aOffset, aNode, aOffset, ignoredError); + NS_WARNING_ASSERTION(!ignoredError.Failed(), + "Creating collapsed range failed"); + return range.forget(); +} + +// mozInlineSpellResume + +class mozInlineSpellResume : public Runnable { + public: + mozInlineSpellResume(UniquePtr<mozInlineSpellStatus>&& aStatus, + uint32_t aDisabledAsyncToken) + : Runnable("mozInlineSpellResume"), + mDisabledAsyncToken(aDisabledAsyncToken), + mStatus(std::move(aStatus)) {} + + nsresult Post() { + nsCOMPtr<nsIRunnable> runnable(this); + return NS_DispatchToCurrentThreadQueue(runnable.forget(), 1000, + EventQueuePriority::Idle); + } + + NS_IMETHOD Run() override { + // Discard the resumption if the spell checker was disabled after the + // resumption was scheduled. + if (mDisabledAsyncToken == + mStatus->mSpellChecker->GetDisabledAsyncToken()) { + mStatus->mSpellChecker->ResumeCheck(std::move(mStatus)); + } + return NS_OK; + } + + private: + uint32_t mDisabledAsyncToken; + UniquePtr<mozInlineSpellStatus> mStatus; +}; + +// Used as the nsIEditorSpellCheck::InitSpellChecker callback. +class InitEditorSpellCheckCallback final : public nsIEditorSpellCheckCallback { + ~InitEditorSpellCheckCallback() {} + + public: + NS_DECL_ISUPPORTS + + explicit InitEditorSpellCheckCallback(mozInlineSpellChecker* aSpellChecker) + : mSpellChecker(aSpellChecker) {} + + NS_IMETHOD EditorSpellCheckDone() override { + return mSpellChecker ? mSpellChecker->EditorSpellCheckInited() : NS_OK; + } + + void Cancel() { mSpellChecker = nullptr; } + + private: + RefPtr<mozInlineSpellChecker> mSpellChecker; +}; +NS_IMPL_ISUPPORTS(InitEditorSpellCheckCallback, nsIEditorSpellCheckCallback) + +NS_INTERFACE_MAP_BEGIN(mozInlineSpellChecker) + NS_INTERFACE_MAP_ENTRY(nsIInlineSpellChecker) + NS_INTERFACE_MAP_ENTRY(nsISupportsWeakReference) + NS_INTERFACE_MAP_ENTRY(nsIDOMEventListener) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIDOMEventListener) + NS_INTERFACE_MAP_ENTRIES_CYCLE_COLLECTION(mozInlineSpellChecker) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(mozInlineSpellChecker) +NS_IMPL_CYCLE_COLLECTING_RELEASE(mozInlineSpellChecker) + +NS_IMPL_CYCLE_COLLECTION_WEAK(mozInlineSpellChecker, mEditorBase, mSpellCheck, + mCurrentSelectionAnchorNode) + +mozInlineSpellChecker::SpellCheckingState + mozInlineSpellChecker::gCanEnableSpellChecking = + mozInlineSpellChecker::SpellCheck_Uninitialized; + +mozInlineSpellChecker::mozInlineSpellChecker() + : mNumWordsInSpellSelection(0), + mMaxNumWordsInSpellSelection( + StaticPrefs::extensions_spellcheck_inline_max_misspellings()), + mNumPendingSpellChecks(0), + mNumPendingUpdateCurrentDictionary(0), + mDisabledAsyncToken(0), + mNeedsCheckAfterNavigation(false), + mFullSpellCheckScheduled(false), + mIsListeningToEditSubActions(false) {} + +mozInlineSpellChecker::~mozInlineSpellChecker() {} + +EditorSpellCheck* mozInlineSpellChecker::GetEditorSpellCheck() { + return mSpellCheck ? mSpellCheck : mPendingSpellCheck; +} + +NS_IMETHODIMP +mozInlineSpellChecker::GetSpellChecker(nsIEditorSpellCheck** aSpellCheck) { + *aSpellCheck = mSpellCheck; + NS_IF_ADDREF(*aSpellCheck); + return NS_OK; +} + +NS_IMETHODIMP +mozInlineSpellChecker::Init(nsIEditor* aEditor) { + mEditorBase = aEditor ? aEditor->AsEditorBase() : nullptr; + return NS_OK; +} + +// mozInlineSpellChecker::Cleanup +// +// Called by the editor when the editor is going away. This is important +// because we remove listeners. We do NOT clean up anything else in this +// function, because it can get called while DoSpellCheck is running! +// +// Getting the style information there can cause DOM notifications to be +// flushed, which can cause editors to go away which will bring us here. +// We can not do anything that will cause DoSpellCheck to freak out. + +MOZ_CAN_RUN_SCRIPT_BOUNDARY nsresult +mozInlineSpellChecker::Cleanup(bool aDestroyingFrames) { + mNumWordsInSpellSelection = 0; + RefPtr<Selection> spellCheckSelection = GetSpellCheckSelection(); + nsresult rv = NS_OK; + if (!spellCheckSelection) { + // Ensure we still unregister event listeners (but return a failure code) + UnregisterEventListeners(); + rv = NS_ERROR_FAILURE; + } else { + if (!aDestroyingFrames) { + spellCheckSelection->RemoveAllRanges(IgnoreErrors()); + } + + rv = UnregisterEventListeners(); + } + + // Notify ENDED observers now. If we wait to notify as we normally do when + // these async operations finish, then in the meantime the editor may create + // another inline spell checker and cause more STARTED and ENDED + // notifications to be broadcast. Interleaved notifications for the same + // editor but different inline spell checkers could easily confuse + // observers. They may receive two consecutive STARTED notifications for + // example, which we guarantee will not happen. + + RefPtr<EditorBase> editorBase = std::move(mEditorBase); + if (mPendingSpellCheck) { + // Cancel the pending editor spell checker initialization. + mPendingSpellCheck = nullptr; + mPendingInitEditorSpellCheckCallback->Cancel(); + mPendingInitEditorSpellCheckCallback = nullptr; + ChangeNumPendingSpellChecks(-1, editorBase); + } + + // Increment this token so that pending UpdateCurrentDictionary calls and + // scheduled spell checks are discarded when they finish. + mDisabledAsyncToken++; + + if (mNumPendingUpdateCurrentDictionary > 0) { + // Account for pending UpdateCurrentDictionary calls. + ChangeNumPendingSpellChecks(-mNumPendingUpdateCurrentDictionary, + editorBase); + mNumPendingUpdateCurrentDictionary = 0; + } + if (mNumPendingSpellChecks > 0) { + // If mNumPendingSpellChecks is still > 0 at this point, the remainder is + // pending scheduled spell checks. + ChangeNumPendingSpellChecks(-mNumPendingSpellChecks, editorBase); + } + + mFullSpellCheckScheduled = false; + + return rv; +} + +// mozInlineSpellChecker::CanEnableInlineSpellChecking +// +// This function can be called to see if it seems likely that we can enable +// spellchecking before actually creating the InlineSpellChecking objects. +// +// The problem is that we can't get the dictionary list without actually +// creating a whole bunch of spellchecking objects. This function tries to +// do that and caches the result so we don't have to keep allocating those +// objects if there are no dictionaries or spellchecking. +// +// Whenever dictionaries are added or removed at runtime, this value must be +// updated before an observer notification is sent out about the change, to +// avoid editors getting a wrong cached result. + +bool // static +mozInlineSpellChecker::CanEnableInlineSpellChecking() { + if (gCanEnableSpellChecking == SpellCheck_Uninitialized) { + gCanEnableSpellChecking = SpellCheck_NotAvailable; + + nsCOMPtr<nsIEditorSpellCheck> spellchecker = new EditorSpellCheck(); + + bool canSpellCheck = false; + nsresult rv = spellchecker->CanSpellCheck(&canSpellCheck); + NS_ENSURE_SUCCESS(rv, false); + + if (canSpellCheck) gCanEnableSpellChecking = SpellCheck_Available; + } + return (gCanEnableSpellChecking == SpellCheck_Available); +} + +void // static +mozInlineSpellChecker::UpdateCanEnableInlineSpellChecking() { + gCanEnableSpellChecking = SpellCheck_Uninitialized; +} + +// mozInlineSpellChecker::RegisterEventListeners +// +// The inline spell checker listens to mouse events and keyboard navigation +// events. + +nsresult mozInlineSpellChecker::RegisterEventListeners() { + if (MOZ_UNLIKELY(NS_WARN_IF(!mEditorBase))) { + return NS_ERROR_FAILURE; + } + + StartToListenToEditSubActions(); + + RefPtr<Document> doc = mEditorBase->GetDocument(); + if (MOZ_UNLIKELY(NS_WARN_IF(!doc))) { + return NS_ERROR_FAILURE; + } + EventListenerManager* eventListenerManager = + doc->GetOrCreateListenerManager(); + if (MOZ_UNLIKELY(NS_WARN_IF(!eventListenerManager))) { + return NS_ERROR_FAILURE; + } + eventListenerManager->AddEventListenerByType( + this, u"blur"_ns, TrustedEventsAtSystemGroupCapture()); + eventListenerManager->AddEventListenerByType( + this, u"click"_ns, TrustedEventsAtSystemGroupCapture()); + eventListenerManager->AddEventListenerByType( + this, u"keydown"_ns, TrustedEventsAtSystemGroupCapture()); + return NS_OK; +} + +// mozInlineSpellChecker::UnregisterEventListeners + +nsresult mozInlineSpellChecker::UnregisterEventListeners() { + if (MOZ_UNLIKELY(NS_WARN_IF(!mEditorBase))) { + return NS_ERROR_FAILURE; + } + + EndListeningToEditSubActions(); + + RefPtr<Document> doc = mEditorBase->GetDocument(); + if (MOZ_UNLIKELY(NS_WARN_IF(!doc))) { + return NS_ERROR_FAILURE; + } + EventListenerManager* eventListenerManager = + doc->GetOrCreateListenerManager(); + if (MOZ_UNLIKELY(NS_WARN_IF(!eventListenerManager))) { + return NS_ERROR_FAILURE; + } + eventListenerManager->RemoveEventListenerByType( + this, u"blur"_ns, TrustedEventsAtSystemGroupCapture()); + eventListenerManager->RemoveEventListenerByType( + this, u"click"_ns, TrustedEventsAtSystemGroupCapture()); + eventListenerManager->RemoveEventListenerByType( + this, u"keydown"_ns, TrustedEventsAtSystemGroupCapture()); + return NS_OK; +} + +// mozInlineSpellChecker::GetEnableRealTimeSpell + +NS_IMETHODIMP +mozInlineSpellChecker::GetEnableRealTimeSpell(bool* aEnabled) { + NS_ENSURE_ARG_POINTER(aEnabled); + *aEnabled = mSpellCheck != nullptr || mPendingSpellCheck != nullptr; + return NS_OK; +} + +// mozInlineSpellChecker::SetEnableRealTimeSpell + +NS_IMETHODIMP +mozInlineSpellChecker::SetEnableRealTimeSpell(bool aEnabled) { + if (!aEnabled) { + mSpellCheck = nullptr; + return Cleanup(false); + } + + if (mSpellCheck) { + // spellcheck the current contents. SpellCheckRange doesn't supply a created + // range to DoSpellCheck, which in our case is the entire range. But this + // optimization doesn't matter because there is nothing in the spellcheck + // selection when starting, which triggers a better optimization. + return SpellCheckRange(nullptr); + } + + if (mPendingSpellCheck) { + // The editor spell checker is already being initialized. + return NS_OK; + } + + mPendingSpellCheck = new EditorSpellCheck(); + mPendingSpellCheck->SetFilterType(nsIEditorSpellCheck::FILTERTYPE_MAIL); + + mPendingInitEditorSpellCheckCallback = new InitEditorSpellCheckCallback(this); + nsresult rv = mPendingSpellCheck->InitSpellChecker( + mEditorBase, false, mPendingInitEditorSpellCheckCallback); + if (NS_FAILED(rv)) { + mPendingSpellCheck = nullptr; + mPendingInitEditorSpellCheckCallback = nullptr; + NS_ENSURE_SUCCESS(rv, rv); + } + + ChangeNumPendingSpellChecks(1); + + return NS_OK; +} + +// Called when nsIEditorSpellCheck::InitSpellChecker completes. +nsresult mozInlineSpellChecker::EditorSpellCheckInited() { + MOZ_ASSERT(mPendingSpellCheck, "Spell check should be pending!"); + + // spell checking is enabled, register our event listeners to track navigation + RegisterEventListeners(); + + mSpellCheck = mPendingSpellCheck; + mPendingSpellCheck = nullptr; + mPendingInitEditorSpellCheckCallback = nullptr; + ChangeNumPendingSpellChecks(-1); + + // spellcheck the current contents. SpellCheckRange doesn't supply a created + // range to DoSpellCheck, which in our case is the entire range. But this + // optimization doesn't matter because there is nothing in the spellcheck + // selection when starting, which triggers a better optimization. + return SpellCheckRange(nullptr); +} + +// Changes the number of pending spell checks by the given delta. If the number +// becomes zero or nonzero, observers are notified. See NotifyObservers for +// info on the aEditor parameter. +void mozInlineSpellChecker::ChangeNumPendingSpellChecks( + int32_t aDelta, EditorBase* aEditorBase) { + int8_t oldNumPending = mNumPendingSpellChecks; + mNumPendingSpellChecks += aDelta; + MOZ_ASSERT(mNumPendingSpellChecks >= 0, + "Unbalanced ChangeNumPendingSpellChecks calls!"); + if (oldNumPending == 0 && mNumPendingSpellChecks > 0) { + NotifyObservers(INLINESPELL_STARTED_TOPIC, aEditorBase); + } else if (oldNumPending > 0 && mNumPendingSpellChecks == 0) { + NotifyObservers(INLINESPELL_ENDED_TOPIC, aEditorBase); + } +} + +// Broadcasts the given topic to observers. aEditor is passed to observers if +// nonnull; otherwise mEditorBase is passed. +void mozInlineSpellChecker::NotifyObservers(const char* aTopic, + EditorBase* aEditorBase) { + nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService(); + if (!os) return; + // XXX Do we need to grab the editor here? If it's necessary, each observer + // should do it instead. + RefPtr<EditorBase> editorBase = aEditorBase ? aEditorBase : mEditorBase.get(); + os->NotifyObservers(static_cast<nsIEditor*>(editorBase.get()), aTopic, + nullptr); +} + +// mozInlineSpellChecker::SpellCheckAfterEditorChange +// +// Called by the editor when nearly anything happens to change the content. +// +// The start and end positions specify a range for the thing that happened, +// but these are usually nullptr, even when you'd think they would be useful +// because you want the range (for example, pasting). We ignore them in +// this case. + +nsresult mozInlineSpellChecker::SpellCheckAfterEditorChange( + EditSubAction aEditSubAction, Selection& aSelection, + nsINode* aPreviousSelectedNode, uint32_t aPreviousSelectedOffset, + nsINode* aStartNode, uint32_t aStartOffset, nsINode* aEndNode, + uint32_t aEndOffset) { + nsresult rv; + if (!mSpellCheck) return NS_OK; // disabling spell checking is not an error + + // this means something has changed, and we never check the current word, + // therefore, we should spellcheck for subsequent caret navigations + mNeedsCheckAfterNavigation = true; + + // the anchor node is the position of the caret + Result<UniquePtr<mozInlineSpellStatus>, nsresult> res = + mozInlineSpellStatus::CreateForEditorChange( + *this, aEditSubAction, aSelection.GetAnchorNode(), + aSelection.AnchorOffset(), aPreviousSelectedNode, + aPreviousSelectedOffset, aStartNode, aStartOffset, aEndNode, + aEndOffset); + if (NS_WARN_IF(res.isErr())) { + return res.unwrapErr(); + } + + rv = ScheduleSpellCheck(res.unwrap()); + NS_ENSURE_SUCCESS(rv, rv); + + // remember the current caret position after every change + SaveCurrentSelectionPosition(); + return NS_OK; +} + +// mozInlineSpellChecker::SpellCheckRange +// +// Spellchecks all the words in the given range. +// Supply a nullptr range and this will check the entire editor. + +nsresult mozInlineSpellChecker::SpellCheckRange(nsRange* aRange) { + if (!mSpellCheck) { + NS_WARNING_ASSERTION( + mPendingSpellCheck, + "Trying to spellcheck, but checking seems to be disabled"); + return NS_ERROR_NOT_INITIALIZED; + } + + UniquePtr<mozInlineSpellStatus> status = + mozInlineSpellStatus::CreateForRange(*this, aRange); + return ScheduleSpellCheck(std::move(status)); +} + +// mozInlineSpellChecker::GetMisspelledWord + +NS_IMETHODIMP +mozInlineSpellChecker::GetMisspelledWord(nsINode* aNode, uint32_t aOffset, + nsRange** newword) { + if (NS_WARN_IF(!aNode)) { + return NS_ERROR_INVALID_ARG; + } + RefPtr<Selection> spellCheckSelection = GetSpellCheckSelection(); + if (NS_WARN_IF(!spellCheckSelection)) { + return NS_ERROR_FAILURE; + } + return IsPointInSelection(*spellCheckSelection, aNode, aOffset, newword); +} + +// mozInlineSpellChecker::ReplaceWord + +NS_IMETHODIMP +mozInlineSpellChecker::ReplaceWord(nsINode* aNode, uint32_t aOffset, + const nsAString& aNewWord) { + if (NS_WARN_IF(!mEditorBase) || NS_WARN_IF(aNewWord.IsEmpty())) { + return NS_ERROR_FAILURE; + } + + RefPtr<nsRange> range; + nsresult res = GetMisspelledWord(aNode, aOffset, getter_AddRefs(range)); + NS_ENSURE_SUCCESS(res, res); + + if (!range) { + return NS_OK; + } + + // In usual cases, any words shouldn't include line breaks, but technically, + // they may include and we need to avoid `HTMLTextAreaElement.value` returns + // \r. Therefore, we need to handle it here. + nsString newWord(aNewWord); + if (mEditorBase->IsTextEditor()) { + nsContentUtils::PlatformToDOMLineBreaks(newWord); + } + + // Blink dispatches cancelable `beforeinput` event at collecting misspelled + // word so that we should allow to dispatch cancelable event. + RefPtr<EditorBase> editorBase(mEditorBase); + DebugOnly<nsresult> rv = editorBase->ReplaceTextAsAction( + newWord, range, EditorBase::AllowBeforeInputEventCancelable::Yes); + NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "Failed to insert the new word"); + return NS_OK; +} + +// mozInlineSpellChecker::AddWordToDictionary + +NS_IMETHODIMP +mozInlineSpellChecker::AddWordToDictionary(const nsAString& word) { + NS_ENSURE_TRUE(mSpellCheck, NS_ERROR_NOT_INITIALIZED); + + nsresult rv = mSpellCheck->AddWordToDictionary(word); + NS_ENSURE_SUCCESS(rv, rv); + + UniquePtr<mozInlineSpellStatus> status = + mozInlineSpellStatus::CreateForSelection(*this); + return ScheduleSpellCheck(std::move(status)); +} + +// mozInlineSpellChecker::RemoveWordFromDictionary + +NS_IMETHODIMP +mozInlineSpellChecker::RemoveWordFromDictionary(const nsAString& word) { + NS_ENSURE_TRUE(mSpellCheck, NS_ERROR_NOT_INITIALIZED); + + nsresult rv = mSpellCheck->RemoveWordFromDictionary(word); + NS_ENSURE_SUCCESS(rv, rv); + + UniquePtr<mozInlineSpellStatus> status = + mozInlineSpellStatus::CreateForRange(*this, nullptr); + return ScheduleSpellCheck(std::move(status)); +} + +// mozInlineSpellChecker::IgnoreWord + +NS_IMETHODIMP +mozInlineSpellChecker::IgnoreWord(const nsAString& word) { + NS_ENSURE_TRUE(mSpellCheck, NS_ERROR_NOT_INITIALIZED); + + nsresult rv = mSpellCheck->IgnoreWordAllOccurrences(word); + NS_ENSURE_SUCCESS(rv, rv); + + UniquePtr<mozInlineSpellStatus> status = + mozInlineSpellStatus::CreateForSelection(*this); + return ScheduleSpellCheck(std::move(status)); +} + +// mozInlineSpellChecker::IgnoreWords + +NS_IMETHODIMP +mozInlineSpellChecker::IgnoreWords(const nsTArray<nsString>& aWordsToIgnore) { + NS_ENSURE_TRUE(mSpellCheck, NS_ERROR_NOT_INITIALIZED); + + // add each word to the ignore list and then recheck the document + for (auto& word : aWordsToIgnore) { + mSpellCheck->IgnoreWordAllOccurrences(word); + } + + UniquePtr<mozInlineSpellStatus> status = + mozInlineSpellStatus::CreateForSelection(*this); + return ScheduleSpellCheck(std::move(status)); +} + +// mozInlineSpellChecker::MakeSpellCheckRange +// +// Given begin and end positions, this function constructs a range as +// required for ScheduleSpellCheck. If the start and end nodes are nullptr, +// then the entire range will be selected, and you can supply -1 as the +// offset to the end range to select all of that node. +// +// If the resulting range would be empty, nullptr is put into *aRange and the +// function succeeds. + +nsresult mozInlineSpellChecker::MakeSpellCheckRange(nsINode* aStartNode, + int32_t aStartOffset, + nsINode* aEndNode, + int32_t aEndOffset, + nsRange** aRange) const { + nsresult rv; + *aRange = nullptr; + + if (NS_WARN_IF(!mEditorBase)) { + return NS_ERROR_FAILURE; + } + + RefPtr<Document> doc = mEditorBase->GetDocument(); + if (NS_WARN_IF(!doc)) { + return NS_ERROR_FAILURE; + } + + RefPtr<nsRange> range = nsRange::Create(doc); + + // possibly use full range of the editor + if (!aStartNode || !aEndNode) { + Element* domRootElement = mEditorBase->GetRoot(); + if (NS_WARN_IF(!domRootElement)) { + return NS_ERROR_FAILURE; + } + aStartNode = aEndNode = domRootElement; + aStartOffset = 0; + aEndOffset = -1; + } + + if (aEndOffset == -1) { + // It's hard to say whether it's better to just do nsINode::GetChildCount or + // get the ChildNodes() and then its length. The latter is faster if we + // keep going through this code for the same nodes (because it caches the + // length). The former is faster if we keep getting different nodes here... + // + // Let's do the thing which can't end up with bad O(N^2) behavior. + aEndOffset = aEndNode->ChildNodes()->Length(); + } + + // sometimes we are are requested to check an empty range (possibly an empty + // document). This will result in assertions later. + if (aStartNode == aEndNode && aStartOffset == aEndOffset) return NS_OK; + + if (aEndOffset) { + rv = range->SetStartAndEnd(aStartNode, aStartOffset, aEndNode, aEndOffset); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + } else { + rv = range->SetStartAndEnd(RawRangeBoundary(aStartNode, aStartOffset), + RangeUtils::GetRawRangeBoundaryAfter(aEndNode)); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + } + + range.swap(*aRange); + return NS_OK; +} + +nsresult mozInlineSpellChecker::SpellCheckBetweenNodes(nsINode* aStartNode, + int32_t aStartOffset, + nsINode* aEndNode, + int32_t aEndOffset) { + RefPtr<nsRange> range; + nsresult rv = MakeSpellCheckRange(aStartNode, aStartOffset, aEndNode, + aEndOffset, getter_AddRefs(range)); + NS_ENSURE_SUCCESS(rv, rv); + + if (!range) return NS_OK; // range is empty: nothing to do + + UniquePtr<mozInlineSpellStatus> status = + mozInlineSpellStatus::CreateForRange(*this, range); + return ScheduleSpellCheck(std::move(status)); +} + +// mozInlineSpellChecker::ShouldSpellCheckNode +// +// There are certain conditions when we don't want to spell check a node. In +// particular quotations, moz signatures, etc. This routine returns false +// for these cases. + +// static +bool mozInlineSpellChecker::ShouldSpellCheckNode(EditorBase* aEditorBase, + nsINode* aNode) { + MOZ_ASSERT(aNode); + if (!aNode->IsContent()) return false; + + nsIContent* content = aNode->AsContent(); + + if (aEditorBase->IsMailEditor()) { + nsIContent* parent = content->GetParent(); + while (parent) { + if (parent->IsHTMLElement(nsGkAtoms::blockquote) && + parent->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::type, + nsGkAtoms::cite, eIgnoreCase)) { + return false; + } + if (parent->IsAnyOfHTMLElements(nsGkAtoms::pre, nsGkAtoms::div) && + parent->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::_class, + nsGkAtoms::mozsignature, + eIgnoreCase)) { + return false; + } + if (parent->IsHTMLElement(nsGkAtoms::div) && + parent->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::_class, + nsGkAtoms::mozfwcontainer, + eIgnoreCase)) { + return false; + } + + parent = parent->GetParent(); + } + } else { + // Check spelling only if the node is editable, and GetSpellcheck() is true + // on the nearest HTMLElement ancestor. + if (!content->IsEditable()) { + return false; + } + + // Make sure that we can always turn on spell checking for inputs/textareas. + // Note that because of the previous check, at this point we know that the + // node is editable. + if (content->IsInNativeAnonymousSubtree()) { + nsIContent* node = content->GetParent(); + while (node && node->IsInNativeAnonymousSubtree()) { + node = node->GetParent(); + } + if (node && node->IsTextControlElement()) { + return true; + } + } + + // Get HTML element ancestor (might be aNode itself, although probably that + // has to be a text node in real life here) + nsIContent* parent = content; + while (!parent->IsHTMLElement()) { + parent = parent->GetParent(); + if (!parent) { + return true; + } + } + + // See if it's spellcheckable + return static_cast<nsGenericHTMLElement*>(parent)->Spellcheck(); + } + + return true; +} + +// mozInlineSpellChecker::ScheduleSpellCheck +// +// This is called by code to do the actual spellchecking. We will set up +// the proper structures for calls to DoSpellCheck. + +nsresult mozInlineSpellChecker::ScheduleSpellCheck( + UniquePtr<mozInlineSpellStatus>&& aStatus) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Debug, + ("%s: mFullSpellCheckScheduled=%i", __FUNCTION__, + mFullSpellCheckScheduled)); + + if (mFullSpellCheckScheduled) { + // Just ignore this; we're going to spell-check everything anyway + return NS_OK; + } + // Cache the value because we are going to move aStatus's ownership to + // the new created mozInlineSpellResume instance. + bool isFullSpellCheck = aStatus->IsFullSpellCheck(); + + RefPtr<mozInlineSpellResume> resume = + new mozInlineSpellResume(std::move(aStatus), mDisabledAsyncToken); + NS_ENSURE_TRUE(resume, NS_ERROR_OUT_OF_MEMORY); + + nsresult rv = resume->Post(); + if (NS_SUCCEEDED(rv)) { + if (isFullSpellCheck) { + // We're going to check everything. Suppress further spell-check attempts + // until that happens. + mFullSpellCheckScheduled = true; + } + ChangeNumPendingSpellChecks(1); + } + return rv; +} + +// mozInlineSpellChecker::DoSpellCheckSelection +// +// Called to re-check all misspelled words. We iterate over all ranges in +// the selection and call DoSpellCheck on them. This is used when a word +// is ignored or added to the dictionary: all instances of that word should +// be removed from the selection. +// +// FIXME-PERFORMANCE: This takes as long as it takes and is not resumable. +// Typically, checking this small amount of text is relatively fast, but +// for large numbers of words, a lag may be noticeable. + +nsresult mozInlineSpellChecker::DoSpellCheckSelection( + mozInlineSpellWordUtil& aWordUtil, Selection* aSpellCheckSelection) { + nsresult rv; + + // clear out mNumWordsInSpellSelection since we'll be rebuilding the ranges. + mNumWordsInSpellSelection = 0; + + // Since we could be modifying the ranges for the spellCheckSelection while + // looping on the spell check selection, keep a separate array of range + // elements inside the selection + nsTArray<RefPtr<nsRange>> ranges; + + const uint32_t rangeCount = aSpellCheckSelection->RangeCount(); + for (const uint32_t idx : IntegerRange(rangeCount)) { + MOZ_ASSERT(aSpellCheckSelection->RangeCount() == rangeCount); + nsRange* range = aSpellCheckSelection->GetRangeAt(idx); + MOZ_ASSERT(range); + if (MOZ_LIKELY(range)) { + ranges.AppendElement(range); + } + } + + // We have saved the ranges above. Clearing the spellcheck selection here + // isn't necessary (rechecking each word will modify it as necessary) but + // provides better performance. By ensuring that no ranges need to be + // removed in DoSpellCheck, we can save checking range inclusion which is + // slow. + aSpellCheckSelection->RemoveAllRanges(IgnoreErrors()); + + // We use this state object for all calls, and just update its range. Note + // that we don't need to call FinishInit since we will be filling in the + // necessary information. + UniquePtr<mozInlineSpellStatus> status = + mozInlineSpellStatus::CreateForRange(*this, nullptr); + + bool doneChecking; + for (uint32_t idx : IntegerRange(rangeCount)) { + // We can consider this word as "added" since we know it has no spell + // check range over it that needs to be deleted. All the old ranges + // were cleared above. We also need to clear the word count so that we + // check all words instead of stopping early. + status->mRange = ranges[idx]; + rv = DoSpellCheck(aWordUtil, aSpellCheckSelection, status, &doneChecking); + NS_ENSURE_SUCCESS(rv, rv); + MOZ_ASSERT( + doneChecking, + "We gave the spellchecker one word, but it didn't finish checking?!?!"); + } + + return NS_OK; +} + +class MOZ_STACK_CLASS mozInlineSpellChecker::SpellCheckerSlice { + public: + /** + * @param aStatus must be non-nullptr. + */ + SpellCheckerSlice(mozInlineSpellChecker& aInlineSpellChecker, + mozInlineSpellWordUtil& aWordUtil, + mozilla::dom::Selection& aSpellCheckSelection, + const mozilla::UniquePtr<mozInlineSpellStatus>& aStatus, + bool& aDoneChecking) + : mInlineSpellChecker{aInlineSpellChecker}, + mWordUtil{aWordUtil}, + mSpellCheckSelection{aSpellCheckSelection}, + mStatus{aStatus}, + mDoneChecking{aDoneChecking} { + MOZ_ASSERT(aStatus); + } + + [[nodiscard]] nsresult Execute(); + + private: + // Creates an async request to check the words and update the ranges for the + // misspellings. + // + // @param aWords normalized words corresponding to aNodeOffsetRangesForWords. + // @param aOldRangesForSomeWords ranges from previous spellcheckings which + // might need to be removed. Its length might + // differ from `aWords.Length()`. + // @param aNodeOffsetRangesForWords One range for each word in aWords. So + // `aNodeOffsetRangesForWords.Length() == + // aWords.Length()`. + void CheckWordsAndUpdateRangesForMisspellings( + const nsTArray<nsString>& aWords, + nsTArray<RefPtr<nsRange>>&& aOldRangesForSomeWords, + nsTArray<NodeOffsetRange>&& aNodeOffsetRangesForWords); + + void RemoveRanges(const nsTArray<RefPtr<nsRange>>& aRanges); + + bool ShouldSpellCheckRange(const nsRange& aRange) const; + + bool IsInNoCheckRange(const nsINode& aNode, int32_t aOffset) const; + + mozInlineSpellChecker& mInlineSpellChecker; + mozInlineSpellWordUtil& mWordUtil; + mozilla::dom::Selection& mSpellCheckSelection; + const mozilla::UniquePtr<mozInlineSpellStatus>& mStatus; + bool& mDoneChecking; +}; + +bool mozInlineSpellChecker::SpellCheckerSlice::ShouldSpellCheckRange( + const nsRange& aRange) const { + if (aRange.Collapsed()) { + return false; + } + + nsINode* beginNode = aRange.GetStartContainer(); + nsINode* endNode = aRange.GetEndContainer(); + + const nsINode* rootNode = mWordUtil.GetRootNode(); + return beginNode->IsInComposedDoc() && endNode->IsInComposedDoc() && + beginNode->IsShadowIncludingInclusiveDescendantOf(rootNode) && + endNode->IsShadowIncludingInclusiveDescendantOf(rootNode); +} + +bool mozInlineSpellChecker::SpellCheckerSlice::IsInNoCheckRange( + const nsINode& aNode, int32_t aOffset) const { + ErrorResult erv; + return mStatus->GetNoCheckRange() && + mStatus->GetNoCheckRange()->IsPointInRange(aNode, aOffset, erv); +} + +void mozInlineSpellChecker::SpellCheckerSlice::RemoveRanges( + const nsTArray<RefPtr<nsRange>>& aRanges) { + for (uint32_t i = 0; i < aRanges.Length(); i++) { + mInlineSpellChecker.RemoveRange(&mSpellCheckSelection, aRanges[i]); + } +} + +// mozInlineSpellChecker::SpellCheckerSlice::Execute +// +// This function checks words intersecting the given range, excluding those +// inside mStatus->mNoCheckRange (can be nullptr). Words inside aNoCheckRange +// will have any spell selection removed (this is used to hide the +// underlining for the word that the caret is in). aNoCheckRange should be +// on word boundaries. +// +// mResume->mCreatedRange is a possibly nullptr range of new text that was +// inserted. Inside this range, we don't bother to check whether things are +// inside the spellcheck selection, which speeds up large paste operations +// considerably. +// +// Normal case when editing text by typing +// h e l l o w o r k d h o w a r e y o u +// ^ caret +// [-------] mRange +// [-------] mNoCheckRange +// -> does nothing (range is the same as the no check range) +// +// Case when pasting: +// [---------- pasted text ----------] +// h e l l o w o r k d h o w a r e y o u +// ^ caret +// [---] aNoCheckRange +// -> recheck all words in range except those in aNoCheckRange +// +// If checking is complete, *aDoneChecking will be set. If there is more +// but we ran out of time, this will be false and the range will be +// updated with the stuff that still needs checking. + +nsresult mozInlineSpellChecker::SpellCheckerSlice::Execute() { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Debug, ("%s", __FUNCTION__)); + + mDoneChecking = true; + + if (NS_WARN_IF(!mInlineSpellChecker.mSpellCheck)) { + return NS_ERROR_NOT_INITIALIZED; + } + + if (mInlineSpellChecker.IsSpellCheckSelectionFull()) { + return NS_OK; + } + + // get the editor for ShouldSpellCheckNode, this may fail in reasonable + // circumstances since the editor could have gone away + RefPtr<EditorBase> editorBase = mInlineSpellChecker.mEditorBase; + if (!editorBase || editorBase->Destroyed()) { + return NS_ERROR_FAILURE; + } + + if (!ShouldSpellCheckRange(*mStatus->mRange)) { + // Just bail out and don't try to spell-check this + return NS_OK; + } + + // see if the selection has any ranges, if not, then we can optimize checking + // range inclusion later (we have no ranges when we are initially checking or + // when there are no misspelled words yet). + const int32_t originalRangeCount = mSpellCheckSelection.RangeCount(); + + // set the starting DOM position to be the beginning of our range + if (nsresult rv = mWordUtil.SetPositionAndEnd( + mStatus->mRange->GetStartContainer(), mStatus->mRange->StartOffset(), + mStatus->mRange->GetEndContainer(), mStatus->mRange->EndOffset()); + NS_FAILED(rv)) { + // Just bail out and don't try to spell-check this + return NS_OK; + } + + // aWordUtil.SetPosition flushes pending notifications, check editor again. + if (!mInlineSpellChecker.mEditorBase) { + return NS_ERROR_FAILURE; + } + + int32_t wordsChecked = 0; + PRTime beginTime = PR_Now(); + + nsTArray<nsString> normalizedWords; + nsTArray<RefPtr<nsRange>> oldRangesToRemove; + nsTArray<NodeOffsetRange> checkRanges; + mozInlineSpellWordUtil::Word word; + static const size_t requestChunkSize = + INLINESPELL_MAXIMUM_CHUNKED_WORDS_PER_TASK; + + while (mWordUtil.GetNextWord(word)) { + // get the range for the current word. + nsINode* const beginNode = word.mNodeOffsetRange.Begin().Node(); + nsINode* const endNode = word.mNodeOffsetRange.End().Node(); + // TODO: Make them `uint32_t` + const int32_t beginOffset = word.mNodeOffsetRange.Begin().Offset(); + const int32_t endOffset = word.mNodeOffsetRange.End().Offset(); + + // see if we've done enough words in this round and run out of time. + if (wordsChecked >= INLINESPELL_MINIMUM_WORDS_BEFORE_TIMEOUT && + PR_Now() > PRTime(beginTime + kMaxSpellCheckTimeInUsec)) { + // stop checking, our time limit has been exceeded. + MOZ_LOG( + sInlineSpellCheckerLog, LogLevel::Verbose, + ("%s: we have run out of time, schedule next round.", __FUNCTION__)); + + CheckWordsAndUpdateRangesForMisspellings(normalizedWords, + std::move(oldRangesToRemove), + std::move(checkRanges)); + + // move the range to encompass the stuff that needs checking. + nsresult rv = mStatus->mRange->SetStart( + beginNode, AssertedCast<uint32_t>(beginOffset)); + if (NS_FAILED(rv)) { + // The range might be unhappy because the beginning is after the + // end. This is possible when the requested end was in the middle + // of a word, just ignore this situation and assume we're done. + return NS_OK; + } + mDoneChecking = false; + return NS_OK; + } + + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Debug, + ("%s: got word \"%s\"%s", __FUNCTION__, + NS_ConvertUTF16toUTF8(word.mText).get(), + word.mSkipChecking ? " (not checking)" : "")); + + // see if there is a spellcheck range that already intersects the word + // and remove it. We only need to remove old ranges, so don't bother if + // there were no ranges when we started out. + if (originalRangeCount > 0) { + ErrorResult erv; + // likewise, if this word is inside new text, we won't bother testing + if (!mStatus->GetCreatedRange() || + !mStatus->GetCreatedRange()->IsPointInRange( + *beginNode, AssertedCast<uint32_t>(beginOffset), erv)) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Debug, + ("%s: removing ranges for some interval.", __FUNCTION__)); + + nsTArray<RefPtr<nsRange>> ranges; + mSpellCheckSelection.GetRangesForInterval( + *beginNode, AssertedCast<uint32_t>(beginOffset), *endNode, + AssertedCast<uint32_t>(endOffset), true, ranges, erv); + ENSURE_SUCCESS(erv, erv.StealNSResult()); + oldRangesToRemove.AppendElements(std::move(ranges)); + } + } + + // some words are special and don't need checking + if (word.mSkipChecking) { + continue; + } + + // some nodes we don't spellcheck + if (!mozInlineSpellChecker::ShouldSpellCheckNode(editorBase, beginNode)) { + continue; + } + + // Don't check spelling if we're inside the noCheckRange. This needs to + // be done after we clear any old selection because the excluded word + // might have been previously marked. + // + // We do a simple check to see if the beginning of our word is in the + // exclusion range. Because the exclusion range is a multiple of a word, + // this is sufficient. + if (IsInNoCheckRange(*beginNode, beginOffset)) { + continue; + } + + // check spelling and add to selection if misspelled + mozInlineSpellWordUtil::NormalizeWord(word.mText); + normalizedWords.AppendElement(word.mText); + checkRanges.AppendElement(word.mNodeOffsetRange); + wordsChecked++; + if (normalizedWords.Length() >= requestChunkSize) { + CheckWordsAndUpdateRangesForMisspellings(normalizedWords, + std::move(oldRangesToRemove), + std::move(checkRanges)); + normalizedWords.Clear(); + oldRangesToRemove = {}; + // Set new empty data for spellcheck range in DOM to avoid + // clang-tidy detection. + checkRanges = nsTArray<NodeOffsetRange>(); + } + } + + CheckWordsAndUpdateRangesForMisspellings( + normalizedWords, std::move(oldRangesToRemove), std::move(checkRanges)); + + return NS_OK; +} + +nsresult mozInlineSpellChecker::DoSpellCheck( + mozInlineSpellWordUtil& aWordUtil, Selection* aSpellCheckSelection, + const UniquePtr<mozInlineSpellStatus>& aStatus, bool* aDoneChecking) { + MOZ_ASSERT(aDoneChecking); + + SpellCheckerSlice spellCheckerSlice{*this, aWordUtil, *aSpellCheckSelection, + aStatus, *aDoneChecking}; + + return spellCheckerSlice.Execute(); +} + +// An RAII helper that calls ChangeNumPendingSpellChecks on destruction. +class MOZ_RAII AutoChangeNumPendingSpellChecks final { + public: + explicit AutoChangeNumPendingSpellChecks(mozInlineSpellChecker* aSpellChecker, + int32_t aDelta) + : mSpellChecker(aSpellChecker), mDelta(aDelta) {} + + ~AutoChangeNumPendingSpellChecks() { + mSpellChecker->ChangeNumPendingSpellChecks(mDelta); + } + + private: + RefPtr<mozInlineSpellChecker> mSpellChecker; + int32_t mDelta; +}; + +void mozInlineSpellChecker::SpellCheckerSlice:: + CheckWordsAndUpdateRangesForMisspellings( + const nsTArray<nsString>& aWords, + nsTArray<RefPtr<nsRange>>&& aOldRangesForSomeWords, + nsTArray<NodeOffsetRange>&& aNodeOffsetRangesForWords) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Verbose, + ("%s: aWords.Length()=%i", __FUNCTION__, + static_cast<int>(aWords.Length()))); + + MOZ_ASSERT(aWords.Length() == aNodeOffsetRangesForWords.Length()); + + // TODO: + // aOldRangesForSomeWords is sorted in the same order as aWords. Could be used + // to remove ranges more efficiently. + + if (aWords.IsEmpty()) { + RemoveRanges(aOldRangesForSomeWords); + return; + } + + mInlineSpellChecker.ChangeNumPendingSpellChecks(1); + + RefPtr<mozInlineSpellChecker> inlineSpellChecker = &mInlineSpellChecker; + RefPtr<Selection> spellCheckerSelection = &mSpellCheckSelection; + uint32_t token = mInlineSpellChecker.mDisabledAsyncToken; + mInlineSpellChecker.mSpellCheck->CheckCurrentWordsNoSuggest(aWords)->Then( + GetMainThreadSerialEventTarget(), __func__, + [inlineSpellChecker, spellCheckerSelection, + nodeOffsetRangesForWords = std::move(aNodeOffsetRangesForWords), + oldRangesForSomeWords = std::move(aOldRangesForSomeWords), + token](const nsTArray<bool>& aIsMisspelled) { + if (token != inlineSpellChecker->GetDisabledAsyncToken()) { + // This result is never used + return; + } + + if (!inlineSpellChecker->mEditorBase || + inlineSpellChecker->mEditorBase->Destroyed()) { + return; + } + + AutoChangeNumPendingSpellChecks pendingChecks(inlineSpellChecker, -1); + + if (inlineSpellChecker->IsSpellCheckSelectionFull()) { + return; + } + + inlineSpellChecker->UpdateRangesForMisspelledWords( + nodeOffsetRangesForWords, oldRangesForSomeWords, aIsMisspelled, + *spellCheckerSelection); + }, + [inlineSpellChecker, token](nsresult aRv) { + if (!inlineSpellChecker->mEditorBase || + inlineSpellChecker->mEditorBase->Destroyed()) { + return; + } + + if (token != inlineSpellChecker->GetDisabledAsyncToken()) { + // This result is never used + return; + } + + inlineSpellChecker->ChangeNumPendingSpellChecks(-1); + }); +} + +// mozInlineSpellChecker::ResumeCheck +// +// Called by the resume event when it fires. We will try to pick up where +// the last resume left off. + +nsresult mozInlineSpellChecker::ResumeCheck( + UniquePtr<mozInlineSpellStatus>&& aStatus) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Debug, ("%s", __FUNCTION__)); + + // Observers should be notified that spell check has ended only after spell + // check is done below, but since there are many early returns in this method + // and the number of pending spell checks must be decremented regardless of + // whether the spell check actually happens, use this RAII object. + AutoChangeNumPendingSpellChecks autoChangeNumPending(this, -1); + + if (aStatus->IsFullSpellCheck()) { + // Allow posting new spellcheck resume events from inside + // ResumeCheck, now that we're actually firing. + MOZ_ASSERT(mFullSpellCheckScheduled, + "How could this be false? The full spell check is " + "calling us!!"); + mFullSpellCheckScheduled = false; + } + + if (!mSpellCheck) return NS_OK; // spell checking has been turned off + + if (!mEditorBase) { + return NS_OK; + } + + Maybe<mozInlineSpellWordUtil> wordUtil{ + mozInlineSpellWordUtil::Create(*mEditorBase)}; + if (!wordUtil) { + return NS_OK; // editor doesn't like us, don't assert + } + + RefPtr<Selection> spellCheckSelection = GetSpellCheckSelection(); + if (NS_WARN_IF(!spellCheckSelection)) { + return NS_ERROR_FAILURE; + } + + nsTArray<nsCString> currentDictionaries; + nsresult rv = mSpellCheck->GetCurrentDictionaries(currentDictionaries); + if (NS_FAILED(rv)) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Debug, + ("%s: no active dictionary.", __FUNCTION__)); + + // no active dictionary + for (const uint32_t index : + Reversed(IntegerRange(spellCheckSelection->RangeCount()))) { + RefPtr<nsRange> checkRange = spellCheckSelection->GetRangeAt(index); + if (MOZ_LIKELY(checkRange)) { + RemoveRange(spellCheckSelection, checkRange); + } + } + return NS_OK; + } + + CleanupRangesInSelection(spellCheckSelection); + + rv = aStatus->FinishInitOnEvent(*wordUtil); + NS_ENSURE_SUCCESS(rv, rv); + if (!aStatus->mRange) return NS_OK; // empty range, nothing to do + + bool doneChecking = true; + if (aStatus->GetOperation() == mozInlineSpellStatus::eOpSelection) + rv = DoSpellCheckSelection(*wordUtil, spellCheckSelection); + else + rv = DoSpellCheck(*wordUtil, spellCheckSelection, aStatus, &doneChecking); + NS_ENSURE_SUCCESS(rv, rv); + + if (!doneChecking) rv = ScheduleSpellCheck(std::move(aStatus)); + return rv; +} + +// mozInlineSpellChecker::IsPointInSelection +// +// Determines if a given (node,offset) point is inside the given +// selection. If so, the specific range of the selection that +// intersects is places in *aRange. (There may be multiple disjoint +// ranges in a selection.) +// +// If there is no intersection, *aRange will be nullptr. + +// static +nsresult mozInlineSpellChecker::IsPointInSelection(Selection& aSelection, + nsINode* aNode, + uint32_t aOffset, + nsRange** aRange) { + *aRange = nullptr; + + nsTArray<nsRange*> ranges; + nsresult rv = aSelection.GetDynamicRangesForIntervalArray( + aNode, aOffset, aNode, aOffset, true, &ranges); + NS_ENSURE_SUCCESS(rv, rv); + + if (ranges.Length() == 0) return NS_OK; // no matches + + // there may be more than one range returned, and we don't know what do + // do with that, so just get the first one + NS_ADDREF(*aRange = ranges[0]); + return NS_OK; +} + +nsresult mozInlineSpellChecker::CleanupRangesInSelection( + Selection* aSelection) { + // integrity check - remove ranges that have collapsed to nothing. This + // can happen if the node containing a highlighted word was removed. + if (!aSelection) return NS_ERROR_FAILURE; + + // TODO: Rewrite this with reversed ranged-loop, it might make this simpler. + int64_t count = aSelection->RangeCount(); + for (int64_t index = 0; index < count; index++) { + nsRange* checkRange = aSelection->GetRangeAt(static_cast<uint32_t>(index)); + if (MOZ_LIKELY(checkRange)) { + if (checkRange->Collapsed()) { + RemoveRange(aSelection, checkRange); + index--; + count--; + } + } + } + + return NS_OK; +} + +// mozInlineSpellChecker::RemoveRange +// +// For performance reasons, we have an upper bound on the number of word +// ranges in the spell check selection. When removing a range from the +// selection, we need to decrement mNumWordsInSpellSelection + +nsresult mozInlineSpellChecker::RemoveRange(Selection* aSpellCheckSelection, + nsRange* aRange) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Debug, ("%s", __FUNCTION__)); + + NS_ENSURE_ARG_POINTER(aSpellCheckSelection); + NS_ENSURE_ARG_POINTER(aRange); + + ErrorResult rv; + RefPtr<nsRange> range{aRange}; + RefPtr<Selection> selection{aSpellCheckSelection}; + selection->RemoveRangeAndUnselectFramesAndNotifyListeners(*range, rv); + if (!rv.Failed()) { + if (mNumWordsInSpellSelection) { + mNumWordsInSpellSelection--; + } +#ifdef ACCESSIBILITY + if (nsAccessibilityService* accService = GetAccService()) { + accService->SpellCheckRangeChanged(*aRange); + } +#endif + } + + return rv.StealNSResult(); +} + +struct mozInlineSpellChecker::CompareRangeAndNodeOffsetRange { + static bool Equals(const RefPtr<nsRange>& aRange, + const NodeOffsetRange& aNodeOffsetRange) { + return aNodeOffsetRange == *aRange; + } +}; + +void mozInlineSpellChecker::UpdateRangesForMisspelledWords( + const nsTArray<NodeOffsetRange>& aNodeOffsetRangesForWords, + const nsTArray<RefPtr<nsRange>>& aOldRangesForSomeWords, + const nsTArray<bool>& aIsMisspelled, Selection& aSpellCheckerSelection) { + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Verbose, ("%s", __FUNCTION__)); + + MOZ_ASSERT(aNodeOffsetRangesForWords.Length() == aIsMisspelled.Length()); + + // When the spellchecker checks text containing words separated by "/", it may + // happen that some words checked in one timeslice, are checked again in a + // following timeslice. E.g. for "foo/baz/qwertz", it may happen that "foo" + // and "baz" are checked in one timeslice and two ranges are added for them. + // In the following timeslice "foo" and "baz" are checked again but since + // their corresponding ranges are already in the spellcheck-Selection + // they don't have to be added again and since "foo" and "baz" still contain + // spelling mistakes, they don't have to be removed. + // + // In this case, it's more efficient to keep the existing ranges. + + AutoTArray<bool, INLINESPELL_MAXIMUM_CHUNKED_WORDS_PER_TASK> + oldRangesMarkedForRemoval; + for (size_t i = 0; i < aOldRangesForSomeWords.Length(); ++i) { + oldRangesMarkedForRemoval.AppendElement(true); + } + + AutoTArray<bool, INLINESPELL_MAXIMUM_CHUNKED_WORDS_PER_TASK> + nodeOffsetRangesMarkedForAdding; + for (size_t i = 0; i < aNodeOffsetRangesForWords.Length(); ++i) { + nodeOffsetRangesMarkedForAdding.AppendElement(false); + } + + for (size_t i = 0; i < aIsMisspelled.Length(); i++) { + if (!aIsMisspelled[i]) { + continue; + } + + const NodeOffsetRange& nodeOffsetRange = aNodeOffsetRangesForWords[i]; + const size_t indexOfOldRangeToKeep = aOldRangesForSomeWords.IndexOf( + nodeOffsetRange, 0, CompareRangeAndNodeOffsetRange{}); + if (indexOfOldRangeToKeep != aOldRangesForSomeWords.NoIndex && + aOldRangesForSomeWords[indexOfOldRangeToKeep]->IsInSelection( + aSpellCheckerSelection)) { + /** TODO: warn in case the old range doesn't + belong to the selection. This is not critical, + because other code can always remove them + before the actual spellchecking happens. */ + MOZ_LOG(sInlineSpellCheckerLog, LogLevel::Verbose, + ("%s: reusing old range.", __FUNCTION__)); + + oldRangesMarkedForRemoval[indexOfOldRangeToKeep] = false; + } else { + nodeOffsetRangesMarkedForAdding[i] = true; + } + } + + for (size_t i = 0; i < oldRangesMarkedForRemoval.Length(); ++i) { + if (oldRangesMarkedForRemoval[i]) { + RemoveRange(&aSpellCheckerSelection, aOldRangesForSomeWords[i]); + } + } + + // Add ranges after removing the marked old ones, so that the Selection can + // become full again. + for (size_t i = 0; i < nodeOffsetRangesMarkedForAdding.Length(); ++i) { + if (nodeOffsetRangesMarkedForAdding[i]) { + RefPtr<nsRange> wordRange = + mozInlineSpellWordUtil::MakeRange(aNodeOffsetRangesForWords[i]); + // If we somehow can't make a range for this word, just ignore + // it. + if (wordRange) { + AddRange(&aSpellCheckerSelection, wordRange); + } + } + } +} + +// mozInlineSpellChecker::AddRange +// +// For performance reasons, we have an upper bound on the number of word +// ranges we'll add to the spell check selection. Once we reach that upper +// bound, stop adding the ranges + +nsresult mozInlineSpellChecker::AddRange(Selection* aSpellCheckSelection, + nsRange* aRange) { + NS_ENSURE_ARG_POINTER(aSpellCheckSelection); + NS_ENSURE_ARG_POINTER(aRange); + + nsresult rv = NS_OK; + + if (!IsSpellCheckSelectionFull()) { + IgnoredErrorResult err; + aSpellCheckSelection->AddRangeAndSelectFramesAndNotifyListeners(*aRange, + err); + if (err.Failed()) { + rv = err.StealNSResult(); + } else { + mNumWordsInSpellSelection++; +#ifdef ACCESSIBILITY + if (nsAccessibilityService* accService = GetAccService()) { + accService->SpellCheckRangeChanged(*aRange); + } +#endif + } + } + + return rv; +} + +already_AddRefed<Selection> mozInlineSpellChecker::GetSpellCheckSelection() { + if (NS_WARN_IF(!mEditorBase)) { + return nullptr; + } + RefPtr<Selection> selection = + mEditorBase->GetSelection(SelectionType::eSpellCheck); + if (!selection) { + return nullptr; + } + return selection.forget(); +} + +nsresult mozInlineSpellChecker::SaveCurrentSelectionPosition() { + if (NS_WARN_IF(!mEditorBase)) { + return NS_OK; // XXX Why NS_OK? + } + + // figure out the old caret position based on the current selection + RefPtr<Selection> selection = mEditorBase->GetSelection(); + if (NS_WARN_IF(!selection)) { + return NS_ERROR_FAILURE; + } + + mCurrentSelectionAnchorNode = selection->GetFocusNode(); + mCurrentSelectionOffset = selection->FocusOffset(); + + return NS_OK; +} + +// mozInlineSpellChecker::HandleNavigationEvent +// +// Acts upon mouse clicks and keyboard navigation changes, spell checking +// the previous word if the new navigation location moves us to another +// word. +// +// This is complicated by the fact that our mouse events are happening after +// selection has been changed to account for the mouse click. But keyboard +// events are happening before the caret selection has changed. Working +// around this by letting keyboard events setting forceWordSpellCheck to +// true. aNewPositionOffset also tries to work around this for the +// DOM_VK_RIGHT and DOM_VK_LEFT cases. + +nsresult mozInlineSpellChecker::HandleNavigationEvent( + bool aForceWordSpellCheck, int32_t aNewPositionOffset) { + nsresult rv; + + // If we already handled the navigation event and there is no possibility + // anything has changed since then, we don't have to do anything. This + // optimization makes a noticeable difference when you hold down a navigation + // key like Page Down. + if (!mNeedsCheckAfterNavigation) return NS_OK; + + nsCOMPtr<nsINode> currentAnchorNode = mCurrentSelectionAnchorNode; + uint32_t currentAnchorOffset = mCurrentSelectionOffset; + + // now remember the new focus position resulting from the event + rv = SaveCurrentSelectionPosition(); + NS_ENSURE_SUCCESS(rv, rv); + + bool shouldPost; + Result<UniquePtr<mozInlineSpellStatus>, nsresult> res = + mozInlineSpellStatus::CreateForNavigation( + *this, aForceWordSpellCheck, aNewPositionOffset, currentAnchorNode, + currentAnchorOffset, mCurrentSelectionAnchorNode, + mCurrentSelectionOffset, &shouldPost); + + if (NS_WARN_IF(res.isErr())) { + return res.unwrapErr(); + } + + if (shouldPost) { + rv = ScheduleSpellCheck(res.unwrap()); + NS_ENSURE_SUCCESS(rv, rv); + } + + return NS_OK; +} + +NS_IMETHODIMP mozInlineSpellChecker::HandleEvent(Event* aEvent) { + WidgetEvent* widgetEvent = aEvent->WidgetEventPtr(); + if (MOZ_UNLIKELY(!widgetEvent)) { + return NS_OK; + } + + switch (widgetEvent->mMessage) { + case eBlur: + OnBlur(*aEvent); + return NS_OK; + case eMouseClick: + OnMouseClick(*aEvent); + return NS_OK; + case eKeyDown: + OnKeyDown(*aEvent); + return NS_OK; + default: + MOZ_ASSERT_UNREACHABLE("You must forgot to handle new event type"); + return NS_OK; + } +} + +void mozInlineSpellChecker::OnBlur(Event& aEvent) { + // force spellcheck on blur, for instance when tabbing out of a textbox + HandleNavigationEvent(true); +} + +void mozInlineSpellChecker::OnMouseClick(Event& aMouseEvent) { + MouseEvent* mouseEvent = aMouseEvent.AsMouseEvent(); + if (MOZ_UNLIKELY(!mouseEvent)) { + return; + } + + // ignore any errors from HandleNavigationEvent as we don't want to prevent + // anyone else from seeing this event. + HandleNavigationEvent(mouseEvent->Button() != 0); +} + +void mozInlineSpellChecker::OnKeyDown(Event& aKeyEvent) { + WidgetKeyboardEvent* widgetKeyboardEvent = + aKeyEvent.WidgetEventPtr()->AsKeyboardEvent(); + if (MOZ_UNLIKELY(!widgetKeyboardEvent)) { + return; + } + + // we only care about navigation keys that moved selection + switch (widgetKeyboardEvent->mKeyNameIndex) { + case KEY_NAME_INDEX_ArrowRight: + // XXX Does this work with RTL text? + HandleNavigationEvent(false, 1); + return; + case KEY_NAME_INDEX_ArrowLeft: + // XXX Does this work with RTL text? + HandleNavigationEvent(false, -1); + return; + case KEY_NAME_INDEX_ArrowUp: + case KEY_NAME_INDEX_ArrowDown: + case KEY_NAME_INDEX_Home: + case KEY_NAME_INDEX_End: + case KEY_NAME_INDEX_PageDown: + case KEY_NAME_INDEX_PageUp: + HandleNavigationEvent(true /* force a spelling correction */); + return; + default: + return; + } +} + +// Used as the nsIEditorSpellCheck::UpdateCurrentDictionary callback. +class UpdateCurrentDictionaryCallback final + : public nsIEditorSpellCheckCallback { + public: + NS_DECL_ISUPPORTS + + explicit UpdateCurrentDictionaryCallback(mozInlineSpellChecker* aSpellChecker, + uint32_t aDisabledAsyncToken) + : mSpellChecker(aSpellChecker), + mDisabledAsyncToken(aDisabledAsyncToken) {} + + NS_IMETHOD EditorSpellCheckDone() override { + // Ignore this callback if SetEnableRealTimeSpell(false) was called after + // the UpdateCurrentDictionary call that triggered it. + return mSpellChecker->GetDisabledAsyncToken() > mDisabledAsyncToken + ? NS_OK + : mSpellChecker->CurrentDictionaryUpdated(); + } + + private: + ~UpdateCurrentDictionaryCallback() {} + + RefPtr<mozInlineSpellChecker> mSpellChecker; + uint32_t mDisabledAsyncToken; +}; +NS_IMPL_ISUPPORTS(UpdateCurrentDictionaryCallback, nsIEditorSpellCheckCallback) + +NS_IMETHODIMP mozInlineSpellChecker::UpdateCurrentDictionary() { + // mSpellCheck is null and mPendingSpellCheck is nonnull while the spell + // checker is being initialized. Calling UpdateCurrentDictionary on + // mPendingSpellCheck simply queues the dictionary update after the init. + RefPtr<EditorSpellCheck> spellCheck = + mSpellCheck ? mSpellCheck : mPendingSpellCheck; + if (!spellCheck) { + return NS_OK; + } + + RefPtr<UpdateCurrentDictionaryCallback> cb = + new UpdateCurrentDictionaryCallback(this, mDisabledAsyncToken); + NS_ENSURE_STATE(cb); + nsresult rv = spellCheck->UpdateCurrentDictionary(cb); + if (NS_FAILED(rv)) { + cb = nullptr; + return rv; + } + mNumPendingUpdateCurrentDictionary++; + ChangeNumPendingSpellChecks(1); + + return NS_OK; +} + +// Called when nsIEditorSpellCheck::UpdateCurrentDictionary completes. +nsresult mozInlineSpellChecker::CurrentDictionaryUpdated() { + mNumPendingUpdateCurrentDictionary--; + MOZ_ASSERT(mNumPendingUpdateCurrentDictionary >= 0, + "CurrentDictionaryUpdated called without corresponding " + "UpdateCurrentDictionary call!"); + ChangeNumPendingSpellChecks(-1); + + nsresult rv = SpellCheckRange(nullptr); + NS_ENSURE_SUCCESS(rv, rv); + + return NS_OK; +} + +NS_IMETHODIMP +mozInlineSpellChecker::GetSpellCheckPending(bool* aPending) { + *aPending = mNumPendingSpellChecks > 0; + return NS_OK; +} diff --git a/extensions/spellcheck/src/mozInlineSpellChecker.h b/extensions/spellcheck/src/mozInlineSpellChecker.h new file mode 100644 index 0000000000..0e304c93a2 --- /dev/null +++ b/extensions/spellcheck/src/mozInlineSpellChecker.h @@ -0,0 +1,338 @@ +/* -*- 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_mozInlineSpellChecker_h +#define mozilla_mozInlineSpellChecker_h + +#include "nsCycleCollectionParticipant.h" +#include "nsIDOMEventListener.h" +#include "nsIEditorSpellCheck.h" +#include "nsIInlineSpellChecker.h" +#include "mozInlineSpellWordUtil.h" +#include "mozilla/EditorDOMPoint.h" +#include "mozilla/Result.h" +#include "nsRange.h" +#include "nsWeakReference.h" + +class InitEditorSpellCheckCallback; +class mozInlineSpellChecker; +class mozInlineSpellResume; +class UpdateCurrentDictionaryCallback; + +namespace mozilla { +class EditorBase; +class EditorSpellCheck; +enum class EditSubAction : int32_t; +enum class JoinNodesDirection; + +namespace dom { +class Event; +} // namespace dom +} // namespace mozilla + +class mozInlineSpellStatus { + public: + static mozilla::Result<mozilla::UniquePtr<mozInlineSpellStatus>, nsresult> + CreateForEditorChange(mozInlineSpellChecker& aSpellChecker, + mozilla::EditSubAction aEditSubAction, + nsINode* aAnchorNode, uint32_t aAnchorOffset, + nsINode* aPreviousNode, uint32_t aPreviousOffset, + nsINode* aStartNode, uint32_t aStartOffset, + nsINode* aEndNode, uint32_t aEndOffset); + + static mozilla::Result<mozilla::UniquePtr<mozInlineSpellStatus>, nsresult> + CreateForNavigation(mozInlineSpellChecker& aSpellChecker, bool aForceCheck, + int32_t aNewPositionOffset, nsINode* aOldAnchorNode, + uint32_t aOldAnchorOffset, nsINode* aNewAnchorNode, + uint32_t aNewAnchorOffset, bool* aContinue); + + static mozilla::UniquePtr<mozInlineSpellStatus> CreateForSelection( + mozInlineSpellChecker& aSpellChecker); + + static mozilla::UniquePtr<mozInlineSpellStatus> CreateForRange( + mozInlineSpellChecker& aSpellChecker, nsRange* aRange); + + nsresult FinishInitOnEvent(mozInlineSpellWordUtil& aWordUtil); + + // Return true if we plan to spell-check everything + bool IsFullSpellCheck() const { return mOp == eOpChange && !mRange; } + + const RefPtr<mozInlineSpellChecker> mSpellChecker; + + enum Operation { + eOpChange, // for SpellCheckAfterEditorChange except + // deleteSelection + eOpChangeDelete, // for SpellCheckAfterEditorChange with + // deleteSelection + eOpNavigation, // for HandleNavigationEvent + eOpSelection, // re-check all misspelled words + eOpResume + }; + + // See `mOp`. + Operation GetOperation() const { return mOp; } + + // Used for events where we have already computed the range to use. It can + // also be nullptr in these cases where we need to check the entire range. + RefPtr<nsRange> mRange; + + // See `mCreatedRange`. + const nsRange* GetCreatedRange() const { return mCreatedRange; } + + // See `mNoCheckRange`. + const nsRange* GetNoCheckRange() const { return mNoCheckRange; } + + private: + // @param aSpellChecker must be non-nullptr. + // @param aOp see mOp. + // @param aRange see mRange. + // @param aCreatedRange see mCreatedRange. + // @param aAnchorRange see mAnchorRange. + // @param aForceNavigationWordCheck see mForceNavigationWordCheck. + // @param aNewNavigationPositionOffset see mNewNavigationPositionOffset. + explicit mozInlineSpellStatus(mozInlineSpellChecker* aSpellChecker, + Operation aOp, RefPtr<nsRange>&& aRange, + RefPtr<nsRange>&& aCreatedRange, + RefPtr<nsRange>&& aAnchorRange, + bool aForceNavigationWordCheck, + int32_t aNewNavigationPositionOffset); + + // For resuming a previously started check. + const Operation mOp; + + // + // If we happen to know something was inserted, this is that range. + // Can be nullptr (this only allows an optimization, so not setting doesn't + // hurt) + const RefPtr<const nsRange> mCreatedRange; + + // Contains the range computed for the current word. Can be nullptr. + RefPtr<nsRange> mNoCheckRange; + + // Indicates the position of the cursor for the event (so we can compute + // mNoCheckRange). It can be nullptr if we don't care about the cursor + // position (such as for the intial check of everything). + // + // For mOp == eOpNavigation, this is the NEW position of the cursor + const RefPtr<const nsRange> mAnchorRange; + + // ----- + // The following members are only for navigation events and are only + // stored for FinishNavigationEvent to initialize the other members. + // ----- + + // this is the OLD position of the cursor + RefPtr<nsRange> mOldNavigationAnchorRange; + + // Set when we should force checking the current word. See + // mozInlineSpellChecker::HandleNavigationEvent for a description of why we + // have this. + const bool mForceNavigationWordCheck; + + // Contains the offset passed in to HandleNavigationEvent + const int32_t mNewNavigationPositionOffset; + + nsresult FinishNavigationEvent(mozInlineSpellWordUtil& aWordUtil); + + nsresult FillNoCheckRangeFromAnchor(mozInlineSpellWordUtil& aWordUtil); + + mozilla::dom::Document* GetDocument() const; + static already_AddRefed<nsRange> PositionToCollapsedRange(nsINode* aNode, + uint32_t aOffset); +}; + +class mozInlineSpellChecker final : public nsIInlineSpellChecker, + public nsIDOMEventListener, + public nsSupportsWeakReference { + private: + friend class mozInlineSpellStatus; + friend class InitEditorSpellCheckCallback; + friend class UpdateCurrentDictionaryCallback; + friend class AutoChangeNumPendingSpellChecks; + + // Access with CanEnableInlineSpellChecking + enum SpellCheckingState { + SpellCheck_Uninitialized = -1, + SpellCheck_NotAvailable = 0, + SpellCheck_Available = 1 + }; + static SpellCheckingState gCanEnableSpellChecking; + + RefPtr<mozilla::EditorBase> mEditorBase; + RefPtr<mozilla::EditorSpellCheck> mSpellCheck; + RefPtr<mozilla::EditorSpellCheck> mPendingSpellCheck; + + int32_t mNumWordsInSpellSelection; + const int32_t mMaxNumWordsInSpellSelection; + + // we need to keep track of the current text position in the document + // so we can spell check the old word when the user clicks around the + // document. + nsCOMPtr<nsINode> mCurrentSelectionAnchorNode; + uint32_t mCurrentSelectionOffset; + + // Tracks the number of pending spell checks *and* async operations that may + // lead to spell checks, like updating the current dictionary. This is + // necessary so that observers can know when to wait for spell check to + // complete. + int32_t mNumPendingSpellChecks; + + // The number of calls to UpdateCurrentDictionary that haven't finished yet. + int32_t mNumPendingUpdateCurrentDictionary; + + // This number is incremented each time the spell checker is disabled so that + // pending scheduled spell checks and UpdateCurrentDictionary calls can be + // ignored when they finish. + uint32_t mDisabledAsyncToken; + + // When mPendingSpellCheck is non-null, this is the callback passed when + // it was initialized. + RefPtr<InitEditorSpellCheckCallback> mPendingInitEditorSpellCheckCallback; + + // Set when we have spellchecked after the last edit operation. See the + // commment at the top of the .cpp file for more info. + bool mNeedsCheckAfterNavigation; + + // Set when we have a pending mozInlineSpellResume which will check + // the whole document. + bool mFullSpellCheckScheduled; + + // Set to true when this instance needs to listen to edit actions of + // the editor. + bool mIsListeningToEditSubActions; + + class SpellCheckerSlice; + + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_NSIINLINESPELLCHECKER + NS_DECL_NSIDOMEVENTLISTENER + NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(mozInlineSpellChecker, + nsIDOMEventListener) + + mozilla::EditorSpellCheck* GetEditorSpellCheck(); + + // See `mDisabledAsyncToken`. + uint32_t GetDisabledAsyncToken() const { return mDisabledAsyncToken; } + + // returns true if there are any spell checking dictionaries available + static bool CanEnableInlineSpellChecking(); + // update the cached value whenever the list of available dictionaries changes + static void UpdateCanEnableInlineSpellChecking(); + + mozInlineSpellChecker(); + + // spell checks all of the words between two nodes + nsresult SpellCheckBetweenNodes(nsINode* aStartNode, int32_t aStartOffset, + nsINode* aEndNode, int32_t aEndOffset); + + // examines the dom node in question and returns true if the inline spell + // checker should skip the node (i.e. the text is inside of a block quote + // or an e-mail signature...) + static bool ShouldSpellCheckNode(mozilla::EditorBase* aEditorBase, + nsINode* aNode); + + // spell check the text contained within aRange, potentially scheduling + // another check in the future if the time threshold is reached + nsresult ScheduleSpellCheck( + mozilla::UniquePtr<mozInlineSpellStatus>&& aStatus); + + MOZ_CAN_RUN_SCRIPT_BOUNDARY nsresult + DoSpellCheckSelection(mozInlineSpellWordUtil& aWordUtil, + mozilla::dom::Selection* aSpellCheckSelection); + + nsresult DoSpellCheck(mozInlineSpellWordUtil& aWordUtil, + mozilla::dom::Selection* aSpellCheckSelection, + const mozilla::UniquePtr<mozInlineSpellStatus>& aStatus, + bool* aDoneChecking); + + // helper routine to determine if a point is inside of the passed in + // selection. + static nsresult IsPointInSelection(mozilla::dom::Selection& aSelection, + nsINode* aNode, uint32_t aOffset, + nsRange** aRange); + + nsresult CleanupRangesInSelection(mozilla::dom::Selection* aSelection); + + /** + * @param aRange needs to be kept alive by the caller. + */ + // TODO: annotate with `MOZ_CAN_RUN_SCRIPT` instead + // (https://bugzilla.mozilla.org/show_bug.cgi?id=1620540). + MOZ_CAN_RUN_SCRIPT_BOUNDARY nsresult + RemoveRange(mozilla::dom::Selection* aSpellCheckSelection, nsRange* aRange); + + MOZ_CAN_RUN_SCRIPT_BOUNDARY nsresult + AddRange(mozilla::dom::Selection* aSpellCheckSelection, nsRange* aRange); + bool IsSpellCheckSelectionFull() const { + return mNumWordsInSpellSelection >= mMaxNumWordsInSpellSelection; + } + + nsresult MakeSpellCheckRange(nsINode* aStartNode, int32_t aStartOffset, + nsINode* aEndNode, int32_t aEndOffset, + nsRange** aRange) const; + + // DOM and editor event registration helper routines + nsresult RegisterEventListeners(); + nsresult UnregisterEventListeners(); + nsresult HandleNavigationEvent(bool aForceWordSpellCheck, + int32_t aNewPositionOffset = 0); + + already_AddRefed<mozilla::dom::Selection> GetSpellCheckSelection(); + nsresult SaveCurrentSelectionPosition(); + + nsresult ResumeCheck(mozilla::UniquePtr<mozInlineSpellStatus>&& aStatus); + + nsresult SpellCheckAfterEditorChange(mozilla::EditSubAction aEditSubAction, + mozilla::dom::Selection& aSelection, + nsINode* aPreviousSelectedNode, + uint32_t aPreviousSelectedOffset, + nsINode* aStartNode, + uint32_t aStartOffset, nsINode* aEndNode, + uint32_t aEndOffset); + + protected: + virtual ~mozInlineSpellChecker(); + + struct CompareRangeAndNodeOffsetRange; + + // Ensures that all misspelled words have corresponding ranges in + // aSpellCheckerSelection. Reuses those of the old ranges, which still + // correspond to misspelled words and adds new ranges for those misspelled + // words for which no corresponding old range exists. + // Removes the old ranges which aren't reused from aSpellCheckerSelection. + // + // @param aNodeOffsetRangesForWords corresponds to aIsMisspelled. + // `aNodeOffsetRangesForWords.Length() == + // aIsMisspelled.Length()`. + // @param aOldRangesForSomeWords ranges belonging to aSpellCheckerSelection. + // Its length may differ from + // `aNodeOffsetRangesForWords.Length()`. + // @param aIsMisspelled indicates which words are misspelled. + MOZ_CAN_RUN_SCRIPT_BOUNDARY void UpdateRangesForMisspelledWords( + const nsTArray<NodeOffsetRange>& aNodeOffsetRangesForWords, + const nsTArray<RefPtr<nsRange>>& aOldRangesForSomeWords, + const nsTArray<bool>& aIsMisspelled, + mozilla::dom::Selection& aSpellCheckerSelection); + + // called when async nsIEditorSpellCheck methods complete + nsresult EditorSpellCheckInited(); + nsresult CurrentDictionaryUpdated(); + + // track the number of pending spell checks and async operations that may lead + // to spell checks, notifying observers accordingly + void ChangeNumPendingSpellChecks(int32_t aDelta, + mozilla::EditorBase* aEditorBase = nullptr); + void NotifyObservers(const char* aTopic, mozilla::EditorBase* aEditorBase); + + void StartToListenToEditSubActions() { mIsListeningToEditSubActions = true; } + void EndListeningToEditSubActions() { mIsListeningToEditSubActions = false; } + + void OnBlur(mozilla::dom::Event& aEvent); + void OnMouseClick(mozilla::dom::Event& aMouseEvent); + void OnKeyDown(mozilla::dom::Event& aKeyEvent); +}; + +#endif // #ifndef mozilla_mozInlineSpellChecker_h diff --git a/extensions/spellcheck/src/mozInlineSpellWordUtil.cpp b/extensions/spellcheck/src/mozInlineSpellWordUtil.cpp new file mode 100644 index 0000000000..731059f04b --- /dev/null +++ b/extensions/spellcheck/src/mozInlineSpellWordUtil.cpp @@ -0,0 +1,1174 @@ +/* -*- 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 "mozInlineSpellWordUtil.h" + +#include <algorithm> +#include <utility> + +#include "mozilla/BinarySearch.h" +#include "mozilla/EditorBase.h" +#include "mozilla/HTMLEditor.h" +#include "mozilla/Logging.h" +#include "mozilla/dom/Element.h" + +#include "nsDebug.h" +#include "nsAtom.h" +#include "nsComponentManagerUtils.h" +#include "nsUnicodeProperties.h" +#include "nsServiceManagerUtils.h" +#include "nsIContent.h" +#include "nsTextFragment.h" +#include "nsRange.h" +#include "nsContentUtils.h" +#include "nsIFrame.h" + +using namespace mozilla; + +static LazyLogModule sInlineSpellWordUtilLog{"InlineSpellWordUtil"}; + +// IsIgnorableCharacter +// +// These characters are ones that we should ignore in input. + +inline bool IsIgnorableCharacter(char ch) { + return (ch == static_cast<char>(0xAD)); // SOFT HYPHEN +} + +inline bool IsIgnorableCharacter(char16_t ch) { + return (ch == 0xAD || // SOFT HYPHEN + ch == 0x1806); // MONGOLIAN TODO SOFT HYPHEN +} + +// IsConditionalPunctuation +// +// Some characters (like apostrophes) require characters on each side to be +// part of a word, and are otherwise punctuation. + +inline bool IsConditionalPunctuation(char ch) { + return (ch == '\'' || // RIGHT SINGLE QUOTATION MARK + ch == static_cast<char>(0xB7)); // MIDDLE DOT +} + +inline bool IsConditionalPunctuation(char16_t ch) { + return (ch == '\'' || ch == 0x2019 || // RIGHT SINGLE QUOTATION MARK + ch == 0x00B7); // MIDDLE DOT +} + +static bool IsAmbiguousDOMWordSeprator(char16_t ch) { + // This class may be CHAR_CLASS_SEPARATOR, but it depends on context. + return (ch == '@' || ch == ':' || ch == '.' || ch == '/' || ch == '-' || + IsConditionalPunctuation(ch)); +} + +static bool IsAmbiguousDOMWordSeprator(char ch) { + // This class may be CHAR_CLASS_SEPARATOR, but it depends on context. + return IsAmbiguousDOMWordSeprator(static_cast<char16_t>(ch)); +} + +// IsDOMWordSeparator +// +// Determines if the given character should be considered as a DOM Word +// separator. Basically, this is whitespace, although it could also have +// certain punctuation that we know ALWAYS breaks words. This is important. +// For example, we can't have any punctuation that could appear in a URL +// or email address in this, because those need to always fit into a single +// DOM word. + +static bool IsDOMWordSeparator(char ch) { + // simple spaces or no-break space + return (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || + ch == static_cast<char>(0xA0)); +} + +static bool IsDOMWordSeparator(char16_t ch) { + // simple spaces + if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') return true; + + // complex spaces - check only if char isn't ASCII (uncommon) + if (ch >= 0xA0 && (ch == 0x00A0 || // NO-BREAK SPACE + ch == 0x2002 || // EN SPACE + ch == 0x2003 || // EM SPACE + ch == 0x2009 || // THIN SPACE + ch == 0x3000)) // IDEOGRAPHIC SPACE + return true; + + // otherwise not a space + return false; +} + +bool NodeOffset::operator==( + const mozilla::RangeBoundary& aRangeBoundary) const { + if (aRangeBoundary.Container() != mNode) { + return false; + } + + const Maybe<uint32_t> rangeBoundaryOffset = + aRangeBoundary.Offset(RangeBoundary::OffsetFilter::kValidOffsets); + + MOZ_ASSERT(mOffset >= 0); + return rangeBoundaryOffset && + (*rangeBoundaryOffset == static_cast<uint32_t>(mOffset)); +} + +bool NodeOffsetRange::operator==(const nsRange& aRange) const { + return mBegin == aRange.StartRef() && mEnd == aRange.EndRef(); +} + +// static +Maybe<mozInlineSpellWordUtil> mozInlineSpellWordUtil::Create( + const EditorBase& aEditorBase) { + dom::Document* document = aEditorBase.GetDocument(); + if (NS_WARN_IF(!document)) { + return Nothing(); + } + + const bool isContentEditableOrDesignMode = aEditorBase.IsHTMLEditor(); + + // Find the root node for the editor. For contenteditable the mRootNode could + // change to shadow root if the begin and end are inside the shadowDOM. + nsINode* rootNode = aEditorBase.GetRoot(); + if (NS_WARN_IF(!rootNode)) { + return Nothing(); + } + + mozInlineSpellWordUtil util{*document, isContentEditableOrDesignMode, + *rootNode}; + return Some(std::move(util)); +} + +static inline bool IsSpellCheckingTextNode(nsINode* aNode) { + nsIContent* parent = aNode->GetParent(); + if (parent && + parent->IsAnyOfHTMLElements(nsGkAtoms::script, nsGkAtoms::style)) + return false; + return aNode->IsText(); +} + +typedef void (*OnLeaveNodeFunPtr)(nsINode* aNode, void* aClosure); + +// Find the next node in the DOM tree in preorder. +// Calls OnLeaveNodeFunPtr when the traversal leaves a node, which is +// why we can't just use GetNextNode here, sadly. +static nsINode* FindNextNode(nsINode* aNode, const nsINode* aRoot, + OnLeaveNodeFunPtr aOnLeaveNode, void* aClosure) { + MOZ_ASSERT(aNode, "Null starting node?"); + + nsINode* next = aNode->GetFirstChild(); + if (next) return next; + + // Don't look at siblings or otherwise outside of aRoot + if (aNode == aRoot) return nullptr; + + next = aNode->GetNextSibling(); + if (next) return next; + + // Go up + for (;;) { + if (aOnLeaveNode) { + aOnLeaveNode(aNode, aClosure); + } + + next = aNode->GetParent(); + if (next == aRoot || !next) return nullptr; + aNode = next; + + next = aNode->GetNextSibling(); + if (next) return next; + } +} + +// aNode is not a text node. Find the first text node starting at aNode/aOffset +// in a preorder DOM traversal. +static nsINode* FindNextTextNode(nsINode* aNode, int32_t aOffset, + const nsINode* aRoot) { + MOZ_ASSERT(aNode, "Null starting node?"); + MOZ_ASSERT(!IsSpellCheckingTextNode(aNode), + "FindNextTextNode should start with a non-text node"); + + nsINode* checkNode; + // Need to start at the aOffset'th child + nsIContent* child = aNode->GetChildAt_Deprecated(aOffset); + + if (child) { + checkNode = child; + } else { + // aOffset was beyond the end of the child list. + // goto next node after the last descendant of aNode in + // a preorder DOM traversal. + checkNode = aNode->GetNextNonChildNode(aRoot); + } + + while (checkNode && !IsSpellCheckingTextNode(checkNode)) { + checkNode = checkNode->GetNextNode(aRoot); + } + return checkNode; +} + +// mozInlineSpellWordUtil::SetPositionAndEnd +// +// We have two ranges "hard" and "soft". The hard boundary is simply +// the scope of the root node. The soft boundary is that which is set +// by the caller of this class by calling this function. If this function is +// not called, the soft boundary is the same as the hard boundary. +// +// When we reach the soft boundary (mSoftText.GetEnd()), we keep +// going until we reach the end of a word. This allows the caller to set the +// end of the range to anything, and we will always check whole multiples of +// words. When we reach the hard boundary we stop no matter what. +// +// There is no beginning soft boundary. This is because we only go to the +// previous node once, when finding the previous word boundary in +// SetPosition(). You might think of the soft boundary as being this initial +// position. + +nsresult mozInlineSpellWordUtil::SetPositionAndEnd(nsINode* aPositionNode, + int32_t aPositionOffset, + nsINode* aEndNode, + int32_t aEndOffset) { + MOZ_LOG(sInlineSpellWordUtilLog, LogLevel::Debug, + ("%s: pos=(%p, %i), end=(%p, %i)", __FUNCTION__, aPositionNode, + aPositionOffset, aEndNode, aEndOffset)); + + MOZ_ASSERT(aPositionNode, "Null begin node?"); + MOZ_ASSERT(aEndNode, "Null end node?"); + + MOZ_ASSERT(mRootNode, "Not initialized"); + + // Find a appropriate root if we are dealing with contenteditable nodes which + // are in the shadow DOM. + if (mIsContentEditableOrDesignMode) { + nsINode* rootNode = aPositionNode->SubtreeRoot(); + if (rootNode != aEndNode->SubtreeRoot()) { + return NS_ERROR_FAILURE; + } + + if (mozilla::dom::ShadowRoot::FromNode(rootNode)) { + mRootNode = rootNode; + } + } + + mSoftText.Invalidate(); + + if (!IsSpellCheckingTextNode(aPositionNode)) { + // Start at the start of the first text node after aNode/aOffset. + aPositionNode = FindNextTextNode(aPositionNode, aPositionOffset, mRootNode); + aPositionOffset = 0; + } + NodeOffset softBegin = NodeOffset(aPositionNode, aPositionOffset); + + if (!IsSpellCheckingTextNode(aEndNode)) { + // End at the start of the first text node after aEndNode/aEndOffset. + aEndNode = FindNextTextNode(aEndNode, aEndOffset, mRootNode); + aEndOffset = 0; + } + NodeOffset softEnd = NodeOffset(aEndNode, aEndOffset); + + nsresult rv = EnsureWords(std::move(softBegin), std::move(softEnd)); + if (NS_FAILED(rv)) { + return rv; + } + + int32_t textOffset = MapDOMPositionToSoftTextOffset(mSoftText.GetBegin()); + if (textOffset < 0) { + return NS_OK; + } + + mNextWordIndex = FindRealWordContaining(textOffset, HINT_END, true); + return NS_OK; +} + +nsresult mozInlineSpellWordUtil::EnsureWords(NodeOffset aSoftBegin, + NodeOffset aSoftEnd) { + if (mSoftText.mIsValid) return NS_OK; + mSoftText.AdjustBeginAndBuildText(std::move(aSoftBegin), std::move(aSoftEnd), + mRootNode); + + mRealWords.Clear(); + Result<RealWords, nsresult> realWords = BuildRealWords(); + if (realWords.isErr()) { + return realWords.unwrapErr(); + } + + mRealWords = realWords.unwrap(); + mSoftText.mIsValid = true; + return NS_OK; +} + +nsresult mozInlineSpellWordUtil::MakeRangeForWord(const RealWord& aWord, + nsRange** aRange) const { + NodeOffset begin = + MapSoftTextOffsetToDOMPosition(aWord.mSoftTextOffset, HINT_BEGIN); + NodeOffset end = MapSoftTextOffsetToDOMPosition(aWord.EndOffset(), HINT_END); + return MakeRange(begin, end, aRange); +} +void mozInlineSpellWordUtil::MakeNodeOffsetRangeForWord( + const RealWord& aWord, NodeOffsetRange* aNodeOffsetRange) { + NodeOffset begin = + MapSoftTextOffsetToDOMPosition(aWord.mSoftTextOffset, HINT_BEGIN); + NodeOffset end = MapSoftTextOffsetToDOMPosition(aWord.EndOffset(), HINT_END); + *aNodeOffsetRange = NodeOffsetRange(begin, end); +} + +// mozInlineSpellWordUtil::GetRangeForWord + +nsresult mozInlineSpellWordUtil::GetRangeForWord(nsINode* aWordNode, + int32_t aWordOffset, + nsRange** aRange) { + // Set our soft end and start + NodeOffset pt(aWordNode, aWordOffset); + + if (!mSoftText.mIsValid || pt != mSoftText.GetBegin() || + pt != mSoftText.GetEnd()) { + mSoftText.Invalidate(); + NodeOffset softBegin = pt; + NodeOffset softEnd = pt; + nsresult rv = EnsureWords(std::move(softBegin), std::move(softEnd)); + if (NS_FAILED(rv)) { + return rv; + } + } + + int32_t offset = MapDOMPositionToSoftTextOffset(pt); + if (offset < 0) return MakeRange(pt, pt, aRange); + int32_t wordIndex = FindRealWordContaining(offset, HINT_BEGIN, false); + if (wordIndex < 0) return MakeRange(pt, pt, aRange); + return MakeRangeForWord(mRealWords[wordIndex], aRange); +} + +// This is to fix characters that the spellchecker may not like +static void NormalizeWord(const nsAString& aInput, int32_t aPos, int32_t aLen, + nsAString& aOutput) { + aOutput.Truncate(); + for (int32_t i = 0; i < aLen; i++) { + char16_t ch = aInput.CharAt(i + aPos); + + // remove ignorable characters from the word + if (IsIgnorableCharacter(ch)) continue; + + // the spellchecker doesn't handle curly apostrophes in all languages + if (ch == 0x2019) { // RIGHT SINGLE QUOTATION MARK + ch = '\''; + } + + aOutput.Append(ch); + } +} + +// mozInlineSpellWordUtil::GetNextWord +// +// FIXME-optimization: we shouldn't have to generate a range every single +// time. It would be better if the inline spellchecker didn't require a +// range unless the word was misspelled. This may or may not be possible. + +bool mozInlineSpellWordUtil::GetNextWord(Word& aWord) { + MOZ_LOG(sInlineSpellWordUtilLog, LogLevel::Debug, + ("%s: mNextWordIndex=%d", __FUNCTION__, mNextWordIndex)); + + if (mNextWordIndex < 0 || mNextWordIndex >= int32_t(mRealWords.Length())) { + mNextWordIndex = -1; + aWord.mSkipChecking = true; + return false; + } + + const RealWord& realWord = mRealWords[mNextWordIndex]; + MakeNodeOffsetRangeForWord(realWord, &aWord.mNodeOffsetRange); + ++mNextWordIndex; + aWord.mSkipChecking = !realWord.mCheckableWord; + ::NormalizeWord(mSoftText.GetValue(), realWord.mSoftTextOffset, + realWord.mLength, aWord.mText); + + MOZ_LOG(sInlineSpellWordUtilLog, LogLevel::Debug, + ("%s: returning: %s (skip=%d)", __FUNCTION__, + NS_ConvertUTF16toUTF8(aWord.mText).get(), aWord.mSkipChecking)); + + return true; +} + +// mozInlineSpellWordUtil::MakeRange +// +// Convenience function for creating a range over the current document. + +nsresult mozInlineSpellWordUtil::MakeRange(NodeOffset aBegin, NodeOffset aEnd, + nsRange** aRange) const { + NS_ENSURE_ARG_POINTER(aBegin.mNode); + if (!mDocument) { + return NS_ERROR_NOT_INITIALIZED; + } + + ErrorResult error; + RefPtr<nsRange> range = nsRange::Create(aBegin.mNode, aBegin.mOffset, + aEnd.mNode, aEnd.mOffset, error); + if (NS_WARN_IF(error.Failed())) { + return error.StealNSResult(); + } + MOZ_ASSERT(range); + range.forget(aRange); + return NS_OK; +} + +// static +already_AddRefed<nsRange> mozInlineSpellWordUtil::MakeRange( + const NodeOffsetRange& aRange) { + IgnoredErrorResult ignoredError; + RefPtr<nsRange> range = + nsRange::Create(aRange.Begin().Node(), aRange.Begin().Offset(), + aRange.End().Node(), aRange.End().Offset(), ignoredError); + NS_WARNING_ASSERTION(!ignoredError.Failed(), "Creating a range failed"); + return range.forget(); +} + +/*********** Word Splitting ************/ + +// classifies a given character in the DOM word +enum CharClass { + CHAR_CLASS_WORD, + CHAR_CLASS_SEPARATOR, + CHAR_CLASS_END_OF_INPUT +}; + +// Encapsulates DOM-word to real-word splitting +template <class T> +struct MOZ_STACK_CLASS WordSplitState { + const T& mDOMWordText; + int32_t mDOMWordOffset; + CharClass mCurCharClass; + + explicit WordSplitState(const T& aString) + : mDOMWordText(aString), + mDOMWordOffset(0), + mCurCharClass(CHAR_CLASS_END_OF_INPUT) {} + + CharClass ClassifyCharacter(int32_t aIndex, bool aRecurse) const; + void Advance(); + void AdvanceThroughSeparators(); + void AdvanceThroughWord(); + + // Finds special words like email addresses and URLs that may start at the + // current position, and returns their length, or 0 if not found. This allows + // arbitrary word breaking rules to be used for these special entities, as + // long as they can not contain whitespace. + bool IsSpecialWord() const; + + // Similar to IsSpecialWord except that this takes a split word as + // input. This checks for things that do not require special word-breaking + // rules. + bool ShouldSkipWord(int32_t aStart, int32_t aLength) const; + + // Finds the last sequence of DOM word separators before aBeforeOffset and + // returns the offset to its first element. + Maybe<int32_t> FindOffsetOfLastDOMWordSeparatorSequence( + int32_t aBeforeOffset) const; + + char16_t GetUnicharAt(int32_t aIndex) const; +}; + +// WordSplitState::ClassifyCharacter +template <class T> +CharClass WordSplitState<T>::ClassifyCharacter(int32_t aIndex, + bool aRecurse) const { + MOZ_ASSERT(aIndex >= 0 && aIndex <= int32_t(mDOMWordText.Length()), + "Index out of range"); + if (aIndex == int32_t(mDOMWordText.Length())) return CHAR_CLASS_SEPARATOR; + + // this will classify the character, we want to treat "ignorable" characters + // such as soft hyphens, and also ZWJ and ZWNJ as word characters. + nsUGenCategory charCategory = + mozilla::unicode::GetGenCategory(GetUnicharAt(aIndex)); + if (charCategory == nsUGenCategory::kLetter || + IsIgnorableCharacter(mDOMWordText[aIndex]) || + mDOMWordText[aIndex] == 0x200C /* ZWNJ */ || + mDOMWordText[aIndex] == 0x200D /* ZWJ */) + return CHAR_CLASS_WORD; + + // If conditional punctuation is surrounded immediately on both sides by word + // characters it also counts as a word character. + if (IsConditionalPunctuation(mDOMWordText[aIndex])) { + if (!aRecurse) { + // not allowed to look around, this punctuation counts like a separator + return CHAR_CLASS_SEPARATOR; + } + + // check the left-hand character + if (aIndex == 0) return CHAR_CLASS_SEPARATOR; + if (ClassifyCharacter(aIndex - 1, false) != CHAR_CLASS_WORD) + return CHAR_CLASS_SEPARATOR; + // If the previous charatcer is a word-char, make sure that it's not a + // special dot character. + if (mDOMWordText[aIndex - 1] == '.') return CHAR_CLASS_SEPARATOR; + + // now we know left char is a word-char, check the right-hand character + if (aIndex == int32_t(mDOMWordText.Length() - 1)) { + return CHAR_CLASS_SEPARATOR; + } + + if (ClassifyCharacter(aIndex + 1, false) != CHAR_CLASS_WORD) + return CHAR_CLASS_SEPARATOR; + // If the next charatcer is a word-char, make sure that it's not a + // special dot character. + if (mDOMWordText[aIndex + 1] == '.') return CHAR_CLASS_SEPARATOR; + + // char on either side is a word, this counts as a word + return CHAR_CLASS_WORD; + } + + // The dot character, if appearing at the end of a word, should + // be considered part of that word. Example: "etc.", or + // abbreviations + if (aIndex > 0 && mDOMWordText[aIndex] == '.' && + mDOMWordText[aIndex - 1] != '.' && + ClassifyCharacter(aIndex - 1, false) != CHAR_CLASS_WORD) { + return CHAR_CLASS_WORD; + } + + // all other punctuation + if (charCategory == nsUGenCategory::kSeparator || + charCategory == nsUGenCategory::kOther || + charCategory == nsUGenCategory::kPunctuation || + charCategory == nsUGenCategory::kSymbol) { + // Don't break on hyphens, as hunspell handles them on its own. + if (aIndex > 0 && mDOMWordText[aIndex] == '-' && + mDOMWordText[aIndex - 1] != '-' && + ClassifyCharacter(aIndex - 1, false) == CHAR_CLASS_WORD) { + // A hyphen is only meaningful as a separator inside a word + // if the previous and next characters are a word character. + if (aIndex == int32_t(mDOMWordText.Length()) - 1) + return CHAR_CLASS_SEPARATOR; + if (mDOMWordText[aIndex + 1] != '.' && + ClassifyCharacter(aIndex + 1, false) == CHAR_CLASS_WORD) + return CHAR_CLASS_WORD; + } + return CHAR_CLASS_SEPARATOR; + } + + // any other character counts as a word + return CHAR_CLASS_WORD; +} + +// WordSplitState::Advance +template <class T> +void WordSplitState<T>::Advance() { + MOZ_ASSERT(mDOMWordOffset >= 0, "Negative word index"); + MOZ_ASSERT(mDOMWordOffset < (int32_t)mDOMWordText.Length(), + "Length beyond end"); + + mDOMWordOffset++; + if (mDOMWordOffset >= (int32_t)mDOMWordText.Length()) + mCurCharClass = CHAR_CLASS_END_OF_INPUT; + else + mCurCharClass = ClassifyCharacter(mDOMWordOffset, true); +} + +// WordSplitState::AdvanceThroughSeparators +template <class T> +void WordSplitState<T>::AdvanceThroughSeparators() { + while (mCurCharClass == CHAR_CLASS_SEPARATOR) Advance(); +} + +// WordSplitState::AdvanceThroughWord +template <class T> +void WordSplitState<T>::AdvanceThroughWord() { + while (mCurCharClass == CHAR_CLASS_WORD) Advance(); +} + +// WordSplitState::IsSpecialWord +template <class T> +bool WordSplitState<T>::IsSpecialWord() const { + // Search for email addresses. We simply define these as any sequence of + // characters with an '@' character in the middle. The DOM word is already + // split on whitepace, so we know that everything to the end is the address + int32_t firstColon = -1; + for (int32_t i = mDOMWordOffset; i < int32_t(mDOMWordText.Length()); i++) { + if (mDOMWordText[i] == '@') { + // only accept this if there are unambiguous word characters (don't bother + // recursing to disambiguate apostrophes) on each side. This prevents + // classifying, e.g. "@home" as an email address + + // Use this condition to only accept words with '@' in the middle of + // them. It works, but the inlinespellcker doesn't like this. The problem + // is that you type "fhsgfh@" that's a misspelled word followed by a + // symbol, but when you type another letter "fhsgfh@g" that first word + // need to be unmarked misspelled. It doesn't do this. it only checks the + // current position for potentially removing a spelling range. + if (i > 0 && ClassifyCharacter(i - 1, false) == CHAR_CLASS_WORD && + i < (int32_t)mDOMWordText.Length() - 1 && + ClassifyCharacter(i + 1, false) == CHAR_CLASS_WORD) { + return true; + } + } else if (mDOMWordText[i] == ':' && firstColon < 0) { + firstColon = i; + + // If the first colon is followed by a slash, consider it a URL + // This will catch things like asdf://foo.com + if (firstColon < (int32_t)mDOMWordText.Length() - 1 && + mDOMWordText[firstColon + 1] == '/') { + return true; + } + } + } + + // Check the text before the first colon against some known protocols. It + // is impossible to check against all protocols, especially since you can + // plug in new protocols. We also don't want to waste time here checking + // against a lot of obscure protocols. + if (firstColon > mDOMWordOffset) { + nsString protocol( + Substring(mDOMWordText, mDOMWordOffset, firstColon - mDOMWordOffset)); + if (protocol.EqualsIgnoreCase("http") || + protocol.EqualsIgnoreCase("https") || + protocol.EqualsIgnoreCase("news") || + protocol.EqualsIgnoreCase("file") || + protocol.EqualsIgnoreCase("javascript") || + protocol.EqualsIgnoreCase("data") || protocol.EqualsIgnoreCase("ftp")) { + return true; + } + } + + // not anything special + return false; +} + +// WordSplitState::ShouldSkipWord +template <class T> +bool WordSplitState<T>::ShouldSkipWord(int32_t aStart, int32_t aLength) const { + int32_t last = aStart + aLength; + + // check to see if the word contains a digit + for (int32_t i = aStart; i < last; i++) { + if (mozilla::unicode::GetGenCategory(GetUnicharAt(i)) == + nsUGenCategory::kNumber) { + return true; + } + } + + // not special + return false; +} + +template <class T> +Maybe<int32_t> WordSplitState<T>::FindOffsetOfLastDOMWordSeparatorSequence( + const int32_t aBeforeOffset) const { + for (int32_t i = aBeforeOffset - 1; i >= 0; --i) { + if (IsDOMWordSeparator(mDOMWordText[i]) || + (!IsAmbiguousDOMWordSeprator(mDOMWordText[i]) && + ClassifyCharacter(i, true) == CHAR_CLASS_SEPARATOR)) { + // Be greedy, find as many separators as we can + for (int32_t j = i - 1; j >= 0; --j) { + if (IsDOMWordSeparator(mDOMWordText[j]) || + (!IsAmbiguousDOMWordSeprator(mDOMWordText[j]) && + ClassifyCharacter(j, true) == CHAR_CLASS_SEPARATOR)) { + i = j; + } else { + break; + } + } + return Some(i); + } + } + return Nothing(); +} + +template <> +char16_t WordSplitState<nsDependentSubstring>::GetUnicharAt( + int32_t aIndex) const { + return mDOMWordText[aIndex]; +} + +template <> +char16_t WordSplitState<nsDependentCSubstring>::GetUnicharAt( + int32_t aIndex) const { + return static_cast<char16_t>(static_cast<uint8_t>(mDOMWordText[aIndex])); +} + +static inline bool IsBRElement(nsINode* aNode) { + return aNode->IsHTMLElement(nsGkAtoms::br); +} + +/** + * Given a TextNode, finds the last sequence of DOM word separators before + * aBeforeOffset and returns the offset to its first element. + * + * @param aContent the TextNode to check. + * @param aBeforeOffset the offset in the TextNode before which we will search + * for the DOM separator. You can pass INT32_MAX to search the entire + * length of the string. + */ +static Maybe<int32_t> FindOffsetOfLastDOMWordSeparatorSequence( + nsIContent* aContent, int32_t aBeforeOffset) { + const nsTextFragment* textFragment = aContent->GetText(); + MOZ_ASSERT(textFragment, "Where is our text?"); + int32_t end = std::min(aBeforeOffset, int32_t(textFragment->GetLength())); + + if (textFragment->Is2b()) { + nsDependentSubstring targetText(textFragment->Get2b(), end); + WordSplitState<nsDependentSubstring> state(targetText); + return state.FindOffsetOfLastDOMWordSeparatorSequence(end); + } + + nsDependentCSubstring targetText(textFragment->Get1b(), end); + WordSplitState<nsDependentCSubstring> state(targetText); + return state.FindOffsetOfLastDOMWordSeparatorSequence(end); +} + +/** + * Check if there's a DOM word separator before aBeforeOffset in this node. + * Always returns true if it's a BR element. + * aSeparatorOffset is set to the index of the first character in the last + * separator if any is found (0 for BR elements). + * + * This function does not modify aSeparatorOffset when it returns false. + */ +static bool ContainsDOMWordSeparator(nsINode* aNode, int32_t aBeforeOffset, + int32_t* aSeparatorOffset) { + if (IsBRElement(aNode)) { + *aSeparatorOffset = 0; + return true; + } + + if (!IsSpellCheckingTextNode(aNode)) return false; + + const Maybe<int32_t> separatorOffset = + FindOffsetOfLastDOMWordSeparatorSequence(aNode->AsContent(), + aBeforeOffset); + if (separatorOffset) { + *aSeparatorOffset = *separatorOffset; + return true; + } + + return false; +} + +static bool IsBreakElement(nsINode* aNode) { + if (!aNode->IsElement()) { + return false; + } + + dom::Element* element = aNode->AsElement(); + if (element->IsHTMLElement(nsGkAtoms::br)) { + return true; + } + + // If we don't have a frame, we don't consider ourselves a break + // element. In particular, words can span us. + nsIFrame* frame = element->GetPrimaryFrame(); + if (!frame) { + return false; + } + + auto* disp = frame->StyleDisplay(); + // Anything that's not an inline element is a break element. + // XXXbz should replaced inlines be break elements, though? + // Also should inline-block and such be break elements? + // + // FIXME(emilio): We should teach the spell checker to deal with generated + // content (it doesn't at all), then remove the IsListItem() check, as there + // could be no marker, etc... + return !disp->IsInlineFlow() || disp->IsListItem(); +} + +struct CheckLeavingBreakElementClosure { + bool mLeftBreakElement; +}; + +static void CheckLeavingBreakElement(nsINode* aNode, void* aClosure) { + CheckLeavingBreakElementClosure* cl = + static_cast<CheckLeavingBreakElementClosure*>(aClosure); + if (!cl->mLeftBreakElement && IsBreakElement(aNode)) { + cl->mLeftBreakElement = true; + } +} + +void mozInlineSpellWordUtil::NormalizeWord(nsAString& aWord) { + nsAutoString result; + ::NormalizeWord(aWord, 0, aWord.Length(), result); + aWord = result; +} + +void mozInlineSpellWordUtil::SoftText::AdjustBeginAndBuildText( + NodeOffset aBegin, NodeOffset aEnd, const nsINode* aRootNode) { + MOZ_LOG(sInlineSpellWordUtilLog, LogLevel::Debug, ("%s", __FUNCTION__)); + + mBegin = std::move(aBegin); + mEnd = std::move(aEnd); + + // First we have to work backwards from mBegin to find a text node + // containing a DOM word separator, a non-inline-element + // boundary, or the hard start node. That's where we'll start building the + // soft string from. + nsINode* node = mBegin.mNode; + int32_t firstOffsetInNode = 0; + int32_t checkBeforeOffset = mBegin.mOffset; + while (node) { + if (ContainsDOMWordSeparator(node, checkBeforeOffset, &firstOffsetInNode)) { + if (node == mBegin.mNode) { + // If we find a word separator on the first node, look at the preceding + // word on the text node as well. + if (firstOffsetInNode > 0) { + // Try to find the previous word boundary in the current node. If + // we can't find one, start checking previous sibling nodes (if any + // adjacent ones exist) to see if we can find any text nodes with + // DOM word separators. We bail out as soon as we see a node that is + // not a text node, or we run out of previous sibling nodes. In the + // event that we simply cannot find any preceding word separator, the + // offset is set to 0, and the soft text beginning node is set to the + // "most previous" text node before the original starting node, or + // kept at the original starting node if no previous text nodes exist. + int32_t newOffset = 0; + if (!ContainsDOMWordSeparator(node, firstOffsetInNode - 1, + &newOffset)) { + nsIContent* prevNode = node->GetPreviousSibling(); + while (prevNode && IsSpellCheckingTextNode(prevNode)) { + mBegin.mNode = prevNode; + const Maybe<int32_t> separatorOffset = + FindOffsetOfLastDOMWordSeparatorSequence(prevNode, INT32_MAX); + if (separatorOffset) { + newOffset = *separatorOffset; + break; + } + prevNode = prevNode->GetPreviousSibling(); + } + } + firstOffsetInNode = newOffset; + } else { + firstOffsetInNode = 0; + } + + MOZ_LOG(sInlineSpellWordUtilLog, LogLevel::Debug, + ("%s: adjusting mBegin.mOffset from %i to %i.", __FUNCTION__, + mBegin.mOffset, firstOffsetInNode)); + mBegin.mOffset = firstOffsetInNode; + } + break; + } + checkBeforeOffset = INT32_MAX; + if (IsBreakElement(node)) { + // Since GetPreviousContent follows tree *preorder*, we're about to + // traverse up out of 'node'. Since node induces breaks (e.g., it's a + // block), don't bother trying to look outside it, just stop now. + break; + } + // GetPreviousContent below expects aRootNode to be an ancestor of node. + if (!node->IsInclusiveDescendantOf(aRootNode)) { + break; + } + node = node->GetPreviousContent(aRootNode); + } + + // Now build up the string moving forward through the DOM until we reach + // the soft end and *then* see a DOM word separator, a non-inline-element + // boundary, or the hard end node. + mValue.Truncate(); + mDOMMapping.Clear(); + bool seenSoftEnd = false; + // Leave this outside the loop so large heap string allocations can be reused + // across iterations + while (node) { + if (node == mEnd.mNode) { + seenSoftEnd = true; + } + + bool exit = false; + if (IsSpellCheckingTextNode(node)) { + nsIContent* content = static_cast<nsIContent*>(node); + MOZ_ASSERT(content, "Where is our content?"); + const nsTextFragment* textFragment = content->GetText(); + MOZ_ASSERT(textFragment, "Where is our text?"); + uint32_t lastOffsetInNode = textFragment->GetLength(); + + if (seenSoftEnd) { + // check whether we can stop after this + for (uint32_t i = + node == mEnd.mNode ? AssertedCast<uint32_t>(mEnd.mOffset) : 0; + i < textFragment->GetLength(); ++i) { + if (IsDOMWordSeparator(textFragment->CharAt(i))) { + exit = true; + // stop at the first separator after the soft end point + lastOffsetInNode = i; + break; + } + } + } + + if (firstOffsetInNode >= 0 && + static_cast<uint32_t>(firstOffsetInNode) < lastOffsetInNode) { + const uint32_t len = lastOffsetInNode - firstOffsetInNode; + mDOMMapping.AppendElement(DOMTextMapping( + NodeOffset(node, firstOffsetInNode), mValue.Length(), len)); + + const bool ok = textFragment->AppendTo( + mValue, static_cast<uint32_t>(firstOffsetInNode), len, + mozilla::fallible); + if (!ok) { + // probably out of memory, remove from mDOMMapping + mDOMMapping.RemoveLastElement(); + exit = true; + } + } + + firstOffsetInNode = 0; + } + + if (exit) break; + + CheckLeavingBreakElementClosure closure = {false}; + node = FindNextNode(node, aRootNode, CheckLeavingBreakElement, &closure); + if (closure.mLeftBreakElement || (node && IsBreakElement(node))) { + // We left, or are entering, a break element (e.g., block). Maybe we can + // stop now. + if (seenSoftEnd) break; + // Record the break + mValue.Append(' '); + } + } + + MOZ_LOG(sInlineSpellWordUtilLog, LogLevel::Debug, + ("%s: got DOM string: %s", __FUNCTION__, + NS_ConvertUTF16toUTF8(mValue).get())); +} + +auto mozInlineSpellWordUtil::BuildRealWords() const + -> Result<RealWords, nsresult> { + // This is pretty simple. We just have to walk mSoftText.GetValue(), + // tokenizing it into "real words". We do an outer traversal of words + // delimited by IsDOMWordSeparator, calling SplitDOMWordAndAppendTo on each of + // those DOM words + int32_t wordStart = -1; + RealWords realWords; + for (int32_t i = 0; i < int32_t(mSoftText.GetValue().Length()); ++i) { + if (IsDOMWordSeparator(mSoftText.GetValue().CharAt(i))) { + if (wordStart >= 0) { + nsresult rv = SplitDOMWordAndAppendTo(wordStart, i, realWords); + if (NS_FAILED(rv)) { + return Err(rv); + } + wordStart = -1; + } + } else { + if (wordStart < 0) { + wordStart = i; + } + } + } + if (wordStart >= 0) { + nsresult rv = SplitDOMWordAndAppendTo( + wordStart, mSoftText.GetValue().Length(), realWords); + if (NS_FAILED(rv)) { + return Err(rv); + } + } + + return realWords; +} + +/*********** DOM/realwords<->mSoftText.GetValue() mapping functions + * ************/ + +int32_t mozInlineSpellWordUtil::MapDOMPositionToSoftTextOffset( + const NodeOffset& aNodeOffset) const { + if (!mSoftText.mIsValid) { + NS_ERROR("Soft text must be valid if we're to map into it"); + return -1; + } + + for (int32_t i = 0; i < int32_t(mSoftText.GetDOMMapping().Length()); ++i) { + const DOMTextMapping& map = mSoftText.GetDOMMapping()[i]; + if (map.mNodeOffset.mNode == aNodeOffset.mNode) { + // Allow offsets at either end of the string, in particular, allow the + // offset that's at the end of the contributed string + int32_t offsetInContributedString = + aNodeOffset.mOffset - map.mNodeOffset.mOffset; + if (offsetInContributedString >= 0 && + offsetInContributedString <= map.mLength) + return map.mSoftTextOffset + offsetInContributedString; + return -1; + } + } + return -1; +} + +namespace { + +template <class T> +class FirstLargerOffset { + int32_t mSoftTextOffset; + + public: + explicit FirstLargerOffset(int32_t aSoftTextOffset) + : mSoftTextOffset(aSoftTextOffset) {} + int operator()(const T& t) const { + // We want the first larger offset, so never return 0 (which would + // short-circuit evaluation before finding the last such offset). + return mSoftTextOffset < t.mSoftTextOffset ? -1 : 1; + } +}; + +template <class T> +bool FindLastNongreaterOffset(const nsTArray<T>& aContainer, + int32_t aSoftTextOffset, size_t* aIndex) { + if (aContainer.Length() == 0) { + return false; + } + + BinarySearchIf(aContainer, 0, aContainer.Length(), + FirstLargerOffset<T>(aSoftTextOffset), aIndex); + if (*aIndex > 0) { + // There was at least one mapping with offset <= aSoftTextOffset. Step back + // to find the last element with |mSoftTextOffset <= aSoftTextOffset|. + *aIndex -= 1; + } else { + // Every mapping had offset greater than aSoftTextOffset. + MOZ_ASSERT(aContainer[*aIndex].mSoftTextOffset > aSoftTextOffset); + } + return true; +} + +} // namespace + +NodeOffset mozInlineSpellWordUtil::MapSoftTextOffsetToDOMPosition( + int32_t aSoftTextOffset, DOMMapHint aHint) const { + MOZ_ASSERT(mSoftText.mIsValid, + "Soft text must be valid if we're to map out of it"); + if (!mSoftText.mIsValid) return NodeOffset(nullptr, -1); + + // Find the last mapping, if any, such that mSoftTextOffset <= aSoftTextOffset + size_t index; + bool found = FindLastNongreaterOffset(mSoftText.GetDOMMapping(), + aSoftTextOffset, &index); + if (!found) { + return NodeOffset(nullptr, -1); + } + + // 'index' is now the last mapping, if any, such that + // mSoftTextOffset <= aSoftTextOffset. + // If we're doing HINT_END, then we may want to return the end of the + // the previous mapping instead of the start of this mapping + if (aHint == HINT_END && index > 0) { + const DOMTextMapping& map = mSoftText.GetDOMMapping()[index - 1]; + if (map.mSoftTextOffset + map.mLength == aSoftTextOffset) + return NodeOffset(map.mNodeOffset.mNode, + map.mNodeOffset.mOffset + map.mLength); + } + + // We allow ourselves to return the end of this mapping even if we're + // doing HINT_START. This will only happen if there is no mapping which this + // point is the start of. I'm not 100% sure this is OK... + const DOMTextMapping& map = mSoftText.GetDOMMapping()[index]; + int32_t offset = aSoftTextOffset - map.mSoftTextOffset; + if (offset >= 0 && offset <= map.mLength) + return NodeOffset(map.mNodeOffset.mNode, map.mNodeOffset.mOffset + offset); + + return NodeOffset(nullptr, -1); +} + +// static +void mozInlineSpellWordUtil::ToString(const DOMMapHint aHint, + nsACString& aResult) { + switch (aHint) { + case HINT_BEGIN: + aResult.AssignLiteral("begin"); + break; + case HINT_END: + aResult.AssignLiteral("end"); + break; + } +} + +int32_t mozInlineSpellWordUtil::FindRealWordContaining( + int32_t aSoftTextOffset, DOMMapHint aHint, bool aSearchForward) const { + if (MOZ_LOG_TEST(sInlineSpellWordUtilLog, LogLevel::Debug)) { + nsAutoCString hint; + mozInlineSpellWordUtil::ToString(aHint, hint); + + MOZ_LOG( + sInlineSpellWordUtilLog, LogLevel::Debug, + ("%s: offset=%i, hint=%s, searchForward=%i.", __FUNCTION__, + aSoftTextOffset, hint.get(), static_cast<int32_t>(aSearchForward))); + } + + MOZ_ASSERT(mSoftText.mIsValid, + "Soft text must be valid if we're to map out of it"); + if (!mSoftText.mIsValid) return -1; + + // Find the last word, if any, such that mRealWords[index].mSoftTextOffset + // <= aSoftTextOffset + size_t index; + bool found = FindLastNongreaterOffset(mRealWords, aSoftTextOffset, &index); + if (!found) { + return -1; + } + + // 'index' is now the last word, if any, such that + // mSoftTextOffset <= aSoftTextOffset. + // If we're doing HINT_END, then we may want to return the end of the + // the previous word instead of the start of this word + if (aHint == HINT_END && index > 0) { + const RealWord& word = mRealWords[index - 1]; + if (word.EndOffset() == aSoftTextOffset) { + return index - 1; + } + } + + // We allow ourselves to return the end of this word even if we're + // doing HINT_BEGIN. This will only happen if there is no word which this + // point is the start of. I'm not 100% sure this is OK... + const RealWord& word = mRealWords[index]; + int32_t offset = aSoftTextOffset - word.mSoftTextOffset; + if (offset >= 0 && offset <= static_cast<int32_t>(word.mLength)) return index; + + if (aSearchForward) { + if (mRealWords[0].mSoftTextOffset > aSoftTextOffset) { + // All words have mSoftTextOffset > aSoftTextOffset + return 0; + } + // 'index' is the last word such that mSoftTextOffset <= aSoftTextOffset. + // Word index+1, if it exists, will be the first with + // mSoftTextOffset > aSoftTextOffset. + if (index + 1 < mRealWords.Length()) return index + 1; + } + + return -1; +} + +// mozInlineSpellWordUtil::SplitDOMWordAndAppendTo + +nsresult mozInlineSpellWordUtil::SplitDOMWordAndAppendTo( + int32_t aStart, int32_t aEnd, nsTArray<RealWord>& aRealWords) const { + nsDependentSubstring targetText(mSoftText.GetValue(), aStart, aEnd - aStart); + WordSplitState<nsDependentSubstring> state(targetText); + state.mCurCharClass = state.ClassifyCharacter(0, true); + + state.AdvanceThroughSeparators(); + if (state.mCurCharClass != CHAR_CLASS_END_OF_INPUT && state.IsSpecialWord()) { + int32_t specialWordLength = + state.mDOMWordText.Length() - state.mDOMWordOffset; + if (!aRealWords.AppendElement( + RealWord(aStart + state.mDOMWordOffset, specialWordLength, false), + fallible)) { + return NS_ERROR_OUT_OF_MEMORY; + } + + return NS_OK; + } + + while (state.mCurCharClass != CHAR_CLASS_END_OF_INPUT) { + state.AdvanceThroughSeparators(); + if (state.mCurCharClass == CHAR_CLASS_END_OF_INPUT) break; + + // save the beginning of the word + int32_t wordOffset = state.mDOMWordOffset; + + // find the end of the word + state.AdvanceThroughWord(); + int32_t wordLen = state.mDOMWordOffset - wordOffset; + if (!aRealWords.AppendElement( + RealWord(aStart + wordOffset, wordLen, + !state.ShouldSkipWord(wordOffset, wordLen)), + fallible)) { + return NS_ERROR_OUT_OF_MEMORY; + } + } + + return NS_OK; +} diff --git a/extensions/spellcheck/src/mozInlineSpellWordUtil.h b/extensions/spellcheck/src/mozInlineSpellWordUtil.h new file mode 100644 index 0000000000..2a1b4b912e --- /dev/null +++ b/extensions/spellcheck/src/mozInlineSpellWordUtil.h @@ -0,0 +1,253 @@ +/* -*- Mode: C++; tab-width: 8; 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 mozInlineSpellWordUtil_h +#define mozInlineSpellWordUtil_h + +#include <utility> + +#include "mozilla/Attributes.h" +#include "mozilla/Maybe.h" +#include "mozilla/RangeBoundary.h" +#include "mozilla/Result.h" +#include "mozilla/dom/Document.h" +#include "nsCOMPtr.h" +#include "nsString.h" +#include "nsTArray.h" + +// #define DEBUG_SPELLCHECK + +class nsRange; +class nsINode; + +namespace mozilla { +class EditorBase; + +namespace dom { +class Document; +} +} // namespace mozilla + +struct NodeOffset { + nsCOMPtr<nsINode> mNode; + int32_t mOffset; + + NodeOffset() : mOffset(0) {} + NodeOffset(nsINode* aNode, int32_t aOffset) + : mNode(aNode), mOffset(aOffset) {} + + bool operator==(const NodeOffset& aOther) const { + return mNode == aOther.mNode && mOffset == aOther.mOffset; + } + + bool operator==(const mozilla::RangeBoundary& aRangeBoundary) const; + + bool operator!=(const NodeOffset& aOther) const { return !(*this == aOther); } + + nsINode* Node() const { return mNode.get(); } + int32_t Offset() const { return mOffset; } +}; + +class NodeOffsetRange { + private: + NodeOffset mBegin; + NodeOffset mEnd; + + public: + NodeOffsetRange() {} + NodeOffsetRange(NodeOffset b, NodeOffset e) + : mBegin(std::move(b)), mEnd(std::move(e)) {} + + bool operator==(const nsRange& aRange) const; + + const NodeOffset& Begin() const { return mBegin; } + + const NodeOffset& End() const { return mEnd; } +}; + +/** + * This class extracts text from the DOM and builds it into a single string. + * The string includes whitespace breaks whereever non-inline elements begin + * and end. This string is broken into "real words", following somewhat + * complex rules; for example substrings that look like URLs or + * email addresses are treated as single words, but otherwise many kinds of + * punctuation are treated as word separators. GetNextWord provides a way + * to iterate over these "real words". + * + * The basic operation is: + * + * 1. Call Init with the editor that you're using. + * 2. Call SetPositionAndEnd to to initialize the current position inside the + * previously given range and set where you want to stop spellchecking. + * We'll stop at the word boundary after that. If SetEnd is not called, + * we'll stop at the end of the root element. + * 3. Call GetNextWord over and over until it returns false. + */ + +class MOZ_STACK_CLASS mozInlineSpellWordUtil { + public: + static mozilla::Maybe<mozInlineSpellWordUtil> Create( + const mozilla::EditorBase& aEditorBase); + + // sets the current position, this should be inside the range. If we are in + // the middle of a word, we'll move to its start. + nsresult SetPositionAndEnd(nsINode* aPositionNode, int32_t aPositionOffset, + nsINode* aEndNode, int32_t aEndOffset); + + // Given a point inside or immediately following a word, this returns the + // DOM range that exactly encloses that word's characters. The current + // position will be at the end of the word. This will find the previous + // word if the current position is space, so if you care that the point is + // inside the word, you should check the range. + // + // THIS CHANGES THE CURRENT POSITION AND RANGE. It is designed to be called + // before you actually generate the range you are interested in and iterate + // the words in it. + nsresult GetRangeForWord(nsINode* aWordNode, int32_t aWordOffset, + nsRange** aRange); + + // Convenience functions, object must be initialized + nsresult MakeRange(NodeOffset aBegin, NodeOffset aEnd, + nsRange** aRange) const; + static already_AddRefed<nsRange> MakeRange(const NodeOffsetRange& aRange); + + struct Word { + nsAutoString mText; + NodeOffsetRange mNodeOffsetRange; + bool mSkipChecking = false; + }; + + // Moves to the the next word in the range, and retrieves it's text and range. + // `false` is returned when we are done checking. + // mSkipChecking will be set if the word is "special" and shouldn't be + // checked (e.g., an email address). + bool GetNextWord(Word& aWord); + + // Call to normalize some punctuation. This function takes an autostring + // so we can access characters directly. + static void NormalizeWord(nsAString& aWord); + + mozilla::dom::Document* GetDocument() const { return mDocument; } + const nsINode* GetRootNode() const { return mRootNode; } + + private: + // A list of where we extracted text from, ordered by mSoftTextOffset. A given + // DOM node appears at most once in this list. + struct DOMTextMapping { + NodeOffset mNodeOffset; + int32_t mSoftTextOffset; + int32_t mLength; + + DOMTextMapping(NodeOffset aNodeOffset, int32_t aSoftTextOffset, + int32_t aLength) + : mNodeOffset(std::move(aNodeOffset)), + mSoftTextOffset(aSoftTextOffset), + mLength(aLength) {} + }; + + struct SoftText { + void AdjustBeginAndBuildText(NodeOffset aBegin, NodeOffset aEnd, + const nsINode* aRootNode); + + void Invalidate() { mIsValid = false; } + + const NodeOffset& GetBegin() const { return mBegin; } + const NodeOffset& GetEnd() const { return mEnd; } + + const nsTArray<DOMTextMapping>& GetDOMMapping() const { + return mDOMMapping; + } + + const nsString& GetValue() const { return mValue; } + + bool mIsValid = false; + + private: + NodeOffset mBegin = NodeOffset(nullptr, 0); + NodeOffset mEnd = NodeOffset(nullptr, 0); + + nsTArray<DOMTextMapping> mDOMMapping; + + // DOM text covering the soft range, with newlines added at block boundaries + nsString mValue; + }; + + SoftText mSoftText; + + mozInlineSpellWordUtil(mozilla::dom::Document& aDocument, + bool aIsContentEditableOrDesignMode, nsINode& aRootNode + + ) + : mDocument(&aDocument), + mIsContentEditableOrDesignMode(aIsContentEditableOrDesignMode), + mRootNode(&aRootNode), + mNextWordIndex(-1) {} + + // cached stuff for the editor + const RefPtr<mozilla::dom::Document> mDocument; + const bool mIsContentEditableOrDesignMode; + + // range to check, see SetPosition and SetEnd + const nsINode* mRootNode; + + // A list of the "real words" in mSoftText.mValue, ordered by mSoftTextOffset + struct RealWord { + int32_t mSoftTextOffset; + uint32_t mLength : 31; + uint32_t mCheckableWord : 1; + + RealWord(int32_t aOffset, uint32_t aLength, bool aCheckable) + : mSoftTextOffset(aOffset), + mLength(aLength), + mCheckableWord(aCheckable) { + static_assert(sizeof(RealWord) == 8, + "RealWord should be limited to 8 bytes"); + MOZ_ASSERT(aLength < INT32_MAX, + "Word length is too large to fit in the bitfield"); + } + + int32_t EndOffset() const { return mSoftTextOffset + mLength; } + }; + using RealWords = nsTArray<RealWord>; + RealWords mRealWords; + int32_t mNextWordIndex; + + nsresult EnsureWords(NodeOffset aSoftBegin, NodeOffset aSoftEnd); + + int32_t MapDOMPositionToSoftTextOffset(const NodeOffset& aNodeOffset) const; + // Map an offset into mSoftText.mValue to a DOM position. Note that two DOM + // positions can map to the same mSoftText.mValue offset, e.g. given nodes + // A=aaaa and B=bbbb forming aaaabbbb, (A,4) and (B,0) give the same string + // offset. So, aHintBefore controls which position we return ... if aHint is + // eEnd then the position indicates the END of a range so we return (A,4). + // Otherwise the position indicates the START of a range so we return (B,0). + enum DOMMapHint { HINT_BEGIN, HINT_END }; + NodeOffset MapSoftTextOffsetToDOMPosition(int32_t aSoftTextOffset, + DOMMapHint aHint) const; + + static void ToString(DOMMapHint aHint, nsACString& aResult); + + // Finds the index of the real word containing aSoftTextOffset, or -1 if none. + // + // If it's exactly between two words, then if aHint is HINT_BEGIN, return the + // later word (favouring the assumption that it's the BEGINning of a word), + // otherwise return the earlier word (assuming it's the END of a word). + // If aSearchForward is true, then if we don't find a word at the given + // position, search forward until we do find a word and return that (if + // found). + int32_t FindRealWordContaining(int32_t aSoftTextOffset, DOMMapHint aHint, + bool aSearchForward) const; + + mozilla::Result<RealWords, nsresult> BuildRealWords() const; + + nsresult SplitDOMWordAndAppendTo(int32_t aStart, int32_t aEnd, + nsTArray<RealWord>& aRealWords) const; + + nsresult MakeRangeForWord(const RealWord& aWord, nsRange** aRange) const; + void MakeNodeOffsetRangeForWord(const RealWord& aWord, + NodeOffsetRange* aNodeOffsetRange); +}; + +#endif diff --git a/extensions/spellcheck/src/mozPersonalDictionary.cpp b/extensions/spellcheck/src/mozPersonalDictionary.cpp new file mode 100644 index 0000000000..752369c034 --- /dev/null +++ b/extensions/spellcheck/src/mozPersonalDictionary.cpp @@ -0,0 +1,433 @@ +/* -*- 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 "mozPersonalDictionary.h" + +#include <utility> + +#include "nsAppDirectoryServiceDefs.h" +#include "nsCRT.h" +#include "nsIFile.h" +#include "nsIInputStream.h" +#include "nsIObserverService.h" +#include "nsIOutputStream.h" +#include "nsIRunnable.h" +#include "nsISafeOutputStream.h" +#include "nsIUnicharInputStream.h" +#include "nsIWeakReference.h" +#include "nsNetCID.h" +#include "nsNetUtil.h" +#include "nsProxyRelease.h" +#include "nsReadableUtils.h" +#include "nsStringEnumerator.h" +#include "nsTArray.h" +#include "nsThreadUtils.h" +#include "nsUnicharInputStream.h" +#include "prio.h" + +#define MOZ_PERSONAL_DICT_NAME u"persdict.dat" + +/** + * This is the most braindead implementation of a personal dictionary possible. + * There is not much complexity needed, though. It could be made much faster, + * and probably should, but I don't see much need for more in terms of + * interface. + * + * Allowing personal words to be associated with only certain dictionaries + * maybe. + * + * TODO: + * Implement the suggestion record. + */ + +NS_IMPL_ADDREF(mozPersonalDictionary) +NS_IMPL_RELEASE(mozPersonalDictionary) + +NS_INTERFACE_MAP_BEGIN(mozPersonalDictionary) + NS_INTERFACE_MAP_ENTRY(mozIPersonalDictionary) + NS_INTERFACE_MAP_ENTRY(nsIObserver) + NS_INTERFACE_MAP_ENTRY(nsISupportsWeakReference) + NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, mozIPersonalDictionary) +NS_INTERFACE_MAP_END + +class mozPersonalDictionaryLoader final : public mozilla::Runnable { + public: + explicit mozPersonalDictionaryLoader(mozPersonalDictionary* dict) + : mozilla::Runnable("mozPersonalDictionaryLoader"), mDict(dict) {} + + NS_IMETHOD Run() override { + mDict->SyncLoad(); + + // Release the dictionary on the main thread + NS_ReleaseOnMainThread("mozPersonalDictionaryLoader::mDict", + mDict.forget().downcast<mozIPersonalDictionary>()); + + return NS_OK; + } + + private: + RefPtr<mozPersonalDictionary> mDict; +}; + +class mozPersonalDictionarySave final : public mozilla::Runnable { + public: + explicit mozPersonalDictionarySave(mozPersonalDictionary* aDict, + nsCOMPtr<nsIFile> aFile, + nsTArray<nsString>&& aDictWords) + : mozilla::Runnable("mozPersonalDictionarySave"), + mDictWords(std::move(aDictWords)), + mFile(aFile), + mDict(aDict) {} + + NS_IMETHOD Run() override { + nsresult res; + + MOZ_ASSERT(!NS_IsMainThread()); + + { + mozilla::MonitorAutoLock mon(mDict->mMonitorSave); + + nsCOMPtr<nsIOutputStream> outStream; + NS_NewSafeLocalFileOutputStream(getter_AddRefs(outStream), mFile, + PR_CREATE_FILE | PR_WRONLY | PR_TRUNCATE, + 0664); + + // Get a buffered output stream 4096 bytes big, to optimize writes. + nsCOMPtr<nsIOutputStream> bufferedOutputStream; + res = NS_NewBufferedOutputStream(getter_AddRefs(bufferedOutputStream), + outStream.forget(), 4096); + if (NS_FAILED(res)) { + return res; + } + + uint32_t bytesWritten; + nsAutoCString utf8Key; + for (uint32_t i = 0; i < mDictWords.Length(); ++i) { + CopyUTF16toUTF8(mDictWords[i], utf8Key); + + bufferedOutputStream->Write(utf8Key.get(), utf8Key.Length(), + &bytesWritten); + bufferedOutputStream->Write("\n", 1, &bytesWritten); + } + nsCOMPtr<nsISafeOutputStream> safeStream = + do_QueryInterface(bufferedOutputStream); + NS_ASSERTION(safeStream, "expected a safe output stream!"); + if (safeStream) { + res = safeStream->Finish(); + if (NS_FAILED(res)) { + NS_WARNING( + "failed to save personal dictionary file! possible data loss"); + } + } + + // Save is done, reset the state variable and notify those who are + // waiting. + mDict->mSavePending = false; + mon.Notify(); + + // Leaving the block where 'mon' was declared will call the destructor + // and unlock. + } + + // Release the dictionary on the main thread. + NS_ReleaseOnMainThread("mozPersonalDictionarySave::mDict", + mDict.forget().downcast<mozIPersonalDictionary>()); + + return NS_OK; + } + + private: + nsTArray<nsString> mDictWords; + nsCOMPtr<nsIFile> mFile; + RefPtr<mozPersonalDictionary> mDict; +}; + +mozPersonalDictionary::mozPersonalDictionary() + : mIsLoaded(false), + mSavePending(false), + mMonitor("mozPersonalDictionary::mMonitor"), + mMonitorSave("mozPersonalDictionary::mMonitorSave") {} + +mozPersonalDictionary::~mozPersonalDictionary() {} + +nsresult mozPersonalDictionary::Init() { + nsCOMPtr<nsIObserverService> svc = + do_GetService("@mozilla.org/observer-service;1"); + + NS_ENSURE_STATE(svc); + // we want to reload the dictionary if the profile switches + nsresult rv = svc->AddObserver(this, "profile-do-change", true); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + rv = svc->AddObserver(this, "profile-before-change", true); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + Load(); + + return NS_OK; +} + +void mozPersonalDictionary::WaitForLoad() { + // If the dictionary is already loaded, we return straight away. + if (mIsLoaded) { + return; + } + + // If the dictionary hasn't been loaded, we try to lock the same monitor + // that the thread uses that does the load. This way the main thread will + // be suspended until the monitor becomes available. + mozilla::MonitorAutoLock mon(mMonitor); + + // The monitor has become available. This can have two reasons: + // 1: The thread that does the load has finished. + // 2: The thread that does the load hasn't even started. + // In this case we need to wait. + if (!mIsLoaded) { + mon.Wait(); + } +} + +nsresult mozPersonalDictionary::LoadInternal() { + nsresult rv; + mozilla::MonitorAutoLock mon(mMonitor); + + if (mIsLoaded) { + return NS_OK; + } + + rv = + NS_GetSpecialDirectory(NS_APP_USER_PROFILE_50_DIR, getter_AddRefs(mFile)); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + if (!mFile) { + return NS_ERROR_FAILURE; + } + + rv = mFile->Append(nsLiteralString(MOZ_PERSONAL_DICT_NAME)); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + nsCOMPtr<nsIEventTarget> target = + do_GetService(NS_STREAMTRANSPORTSERVICE_CONTRACTID, &rv); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + nsCOMPtr<nsIRunnable> runnable = new mozPersonalDictionaryLoader(this); + rv = target->Dispatch(runnable, NS_DISPATCH_NORMAL); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + return NS_OK; +} + +NS_IMETHODIMP mozPersonalDictionary::Load() { + nsresult rv = LoadInternal(); + + if (NS_FAILED(rv)) { + mIsLoaded = true; + } + + return rv; +} + +void mozPersonalDictionary::SyncLoad() { + MOZ_ASSERT(!NS_IsMainThread()); + + mozilla::MonitorAutoLock mon(mMonitor); + + if (mIsLoaded) { + return; + } + + SyncLoadInternal(); + mIsLoaded = true; + mon.Notify(); +} + +void mozPersonalDictionary::SyncLoadInternal() { + MOZ_ASSERT(!NS_IsMainThread()); + + // FIXME Deinst -- get dictionary name from prefs; + nsresult rv; + bool dictExists; + + rv = mFile->Exists(&dictExists); + if (NS_FAILED(rv)) { + return; + } + + if (!dictExists) { + // Nothing is really wrong... + return; + } + + nsCOMPtr<nsIInputStream> inStream; + NS_NewLocalFileInputStream(getter_AddRefs(inStream), mFile); + + nsCOMPtr<nsIUnicharInputStream> convStream; + rv = NS_NewUnicharInputStream(inStream, getter_AddRefs(convStream)); + if (NS_FAILED(rv)) { + return; + } + + // we're rereading to get rid of the old data -- we shouldn't have any, + // but... + mDictionaryTable.Clear(); + + char16_t c; + uint32_t nRead; + bool done = false; + do { // read each line of text into the string array. + if ((NS_OK != convStream->Read(&c, 1, &nRead)) || (nRead != 1)) break; + while (!done && ((c == '\n') || (c == '\r'))) { + if ((NS_OK != convStream->Read(&c, 1, &nRead)) || (nRead != 1)) + done = true; + } + if (!done) { + nsAutoString word; + while ((c != '\n') && (c != '\r') && !done) { + word.Append(c); + if ((NS_OK != convStream->Read(&c, 1, &nRead)) || (nRead != 1)) + done = true; + } + mDictionaryTable.Insert(word); + } + } while (!done); +} + +void mozPersonalDictionary::WaitForSave() { + // If no save is pending, we return straight away. + if (!mSavePending) { + return; + } + + // If a save is pending, we try to lock the same monitor that the thread uses + // that does the save. This way the main thread will be suspended until the + // monitor becomes available. + mozilla::MonitorAutoLock mon(mMonitorSave); + + // The monitor has become available. This can have two reasons: + // 1: The thread that does the save has finished. + // 2: The thread that does the save hasn't even started. + // In this case we need to wait. + if (mSavePending) { + mon.Wait(); + } +} + +NS_IMETHODIMP mozPersonalDictionary::Save() { + nsCOMPtr<nsIFile> theFile; + nsresult res; + + WaitForSave(); + + mSavePending = true; + + // FIXME Deinst -- get dictionary name from prefs; + res = NS_GetSpecialDirectory(NS_APP_USER_PROFILE_50_DIR, + getter_AddRefs(theFile)); + if (NS_FAILED(res)) return res; + if (!theFile) return NS_ERROR_FAILURE; + res = theFile->Append(nsLiteralString(MOZ_PERSONAL_DICT_NAME)); + if (NS_FAILED(res)) return res; + + nsCOMPtr<nsIEventTarget> target = + do_GetService(NS_STREAMTRANSPORTSERVICE_CONTRACTID, &res); + if (NS_WARN_IF(NS_FAILED(res))) { + return res; + } + + nsCOMPtr<nsIRunnable> runnable = new mozPersonalDictionarySave( + this, theFile, mozilla::ToTArray<nsTArray<nsString>>(mDictionaryTable)); + res = target->Dispatch(runnable, NS_DISPATCH_NORMAL); + if (NS_WARN_IF(NS_FAILED(res))) { + return res; + } + return res; +} + +NS_IMETHODIMP mozPersonalDictionary::GetWordList(nsIStringEnumerator** aWords) { + NS_ENSURE_ARG_POINTER(aWords); + *aWords = nullptr; + + WaitForLoad(); + + nsTArray<nsString>* array = new nsTArray<nsString>( + mozilla::ToTArray<nsTArray<nsString>>(mDictionaryTable)); + + array->Sort(); + + return NS_NewAdoptingStringEnumerator(aWords, array); +} + +NS_IMETHODIMP +mozPersonalDictionary::Check(const nsAString& aWord, bool* aResult) { + NS_ENSURE_ARG_POINTER(aResult); + + WaitForLoad(); + + *aResult = (mDictionaryTable.Contains(aWord) || mIgnoreTable.Contains(aWord)); + return NS_OK; +} + +NS_IMETHODIMP +mozPersonalDictionary::AddWord(const nsAString& aWord) { + nsresult res; + WaitForLoad(); + + mDictionaryTable.Insert(aWord); + res = Save(); + return res; +} + +NS_IMETHODIMP +mozPersonalDictionary::RemoveWord(const nsAString& aWord) { + nsresult res; + WaitForLoad(); + + mDictionaryTable.Remove(aWord); + res = Save(); + return res; +} + +NS_IMETHODIMP +mozPersonalDictionary::IgnoreWord(const nsAString& aWord) { + // avoid adding duplicate words to the ignore list + mIgnoreTable.EnsureInserted(aWord); + return NS_OK; +} + +NS_IMETHODIMP mozPersonalDictionary::EndSession() { + WaitForLoad(); + + WaitForSave(); + mIgnoreTable.Clear(); + return NS_OK; +} + +NS_IMETHODIMP mozPersonalDictionary::Observe(nsISupports* aSubject, + const char* aTopic, + const char16_t* aData) { + if (!nsCRT::strcmp(aTopic, "profile-do-change")) { + // The observer is registered in Init() which calls Load and in turn + // LoadInternal(); i.e. Observe() can't be called before Load(). + WaitForLoad(); + mIsLoaded = false; + Load(); // load automatically clears out the existing dictionary table + } else if (!nsCRT::strcmp(aTopic, "profile-before-change")) { + WaitForSave(); + } + + return NS_OK; +} diff --git a/extensions/spellcheck/src/mozPersonalDictionary.h b/extensions/spellcheck/src/mozPersonalDictionary.h new file mode 100644 index 0000000000..584c7edf56 --- /dev/null +++ b/extensions/spellcheck/src/mozPersonalDictionary.h @@ -0,0 +1,80 @@ +/* -*- 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 mozPersonalDictionary_h__ +#define mozPersonalDictionary_h__ + +#include "nsCOMPtr.h" +#include "nsString.h" +#include "mozIPersonalDictionary.h" +#include "nsIObserver.h" +#include "nsWeakReference.h" +#include "nsTHashSet.h" +#include "nsCRT.h" +#include "nsCycleCollectionParticipant.h" +#include "nsHashKeys.h" +#include <mozilla/Monitor.h> + +#define MOZ_PERSONALDICTIONARY_CONTRACTID \ + "@mozilla.org/spellchecker/personaldictionary;1" +#define MOZ_PERSONALDICTIONARY_CID \ + { /* 7EF52EAF-B7E1-462B-87E2-5D1DBACA9048 */ \ + 0X7EF52EAF, 0XB7E1, 0X462B, { \ + 0X87, 0XE2, 0X5D, 0X1D, 0XBA, 0XCA, 0X90, 0X48 \ + } \ + } + +class mozPersonalDictionaryLoader; +class mozPersonalDictionarySave; + +class mozPersonalDictionary final : public mozIPersonalDictionary, + public nsIObserver, + public nsSupportsWeakReference { + public: + NS_DECL_ISUPPORTS + NS_DECL_MOZIPERSONALDICTIONARY + NS_DECL_NSIOBSERVER + + mozPersonalDictionary(); + + nsresult Init(); + + protected: + virtual ~mozPersonalDictionary(); + + /* true if the dictionary has been loaded from disk */ + bool mIsLoaded; + + /* true if a dictionary save is pending */ + bool mSavePending; + + nsCOMPtr<nsIFile> mFile; + mozilla::Monitor mMonitor MOZ_UNANNOTATED; + mozilla::Monitor mMonitorSave MOZ_UNANNOTATED; + nsTHashSet<nsString> mDictionaryTable; + nsTHashSet<nsString> mIgnoreTable; + + private: + /* wait for the asynchronous load of the dictionary to be completed */ + void WaitForLoad(); + + /* enter the monitor before starting a synchronous load off the main-thread */ + void SyncLoad(); + + /* launch an asynchrounous load of the dictionary from the main-thread + * after successfully initializing mFile with the path of the dictionary */ + nsresult LoadInternal(); + + /* perform a synchronous load of the dictionary from disk */ + void SyncLoadInternal(); + + /* wait for the asynchronous save of the dictionary to be completed */ + void WaitForSave(); + + friend class mozPersonalDictionaryLoader; + friend class mozPersonalDictionarySave; +}; + +#endif diff --git a/extensions/spellcheck/src/mozSpellChecker.cpp b/extensions/spellcheck/src/mozSpellChecker.cpp new file mode 100644 index 0000000000..d5b0537bfe --- /dev/null +++ b/extensions/spellcheck/src/mozSpellChecker.cpp @@ -0,0 +1,681 @@ +/* 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 "mozSpellChecker.h" +#include "nsIStringEnumerator.h" +#include "nsICategoryManager.h" +#include "nsISupportsPrimitives.h" +#include "nsISimpleEnumerator.h" +#include "mozEnglishWordUtils.h" +#include "mozilla/dom/ContentChild.h" +#include "mozilla/Logging.h" +#include "mozilla/PRemoteSpellcheckEngineChild.h" +#include "mozilla/TextServicesDocument.h" +#include "nsXULAppAPI.h" +#include "RemoteSpellCheckEngineChild.h" + +using mozilla::AssertedCast; +using mozilla::GenericPromise; +using mozilla::LogLevel; +using mozilla::RemoteSpellcheckEngineChild; +using mozilla::TextServicesDocument; +using mozilla::dom::ContentChild; + +#define DEFAULT_SPELL_CHECKER "@mozilla.org/spellchecker/engine;1" + +static mozilla::LazyLogModule sSpellChecker("SpellChecker"); + +NS_IMPL_CYCLE_COLLECTION(mozSpellChecker, mTextServicesDocument, + mPersonalDictionary) + +mozSpellChecker::mozSpellChecker() : mEngine(nullptr) {} + +mozSpellChecker::~mozSpellChecker() { + if (mPersonalDictionary) { + // mPersonalDictionary->Save(); + mPersonalDictionary->EndSession(); + } + mSpellCheckingEngine = nullptr; + mPersonalDictionary = nullptr; + + if (mEngine) { + MOZ_ASSERT(XRE_IsContentProcess()); + RemoteSpellcheckEngineChild::Send__delete__(mEngine); + MOZ_ASSERT(!mEngine); + } +} + +nsresult mozSpellChecker::Init() { + mSpellCheckingEngine = nullptr; + if (XRE_IsContentProcess()) { + mozilla::dom::ContentChild* contentChild = + mozilla::dom::ContentChild::GetSingleton(); + MOZ_ASSERT(contentChild); + mEngine = new RemoteSpellcheckEngineChild(this); + contentChild->SendPRemoteSpellcheckEngineConstructor(mEngine); + } else { + mPersonalDictionary = + do_GetService("@mozilla.org/spellchecker/personaldictionary;1"); + } + + return NS_OK; +} + +TextServicesDocument* mozSpellChecker::GetTextServicesDocument() { + return mTextServicesDocument; +} + +nsresult mozSpellChecker::SetDocument( + TextServicesDocument* aTextServicesDocument, bool aFromStartofDoc) { + MOZ_LOG(sSpellChecker, LogLevel::Debug, ("%s", __FUNCTION__)); + + mTextServicesDocument = aTextServicesDocument; + mFromStart = aFromStartofDoc; + return NS_OK; +} + +nsresult mozSpellChecker::NextMisspelledWord(nsAString& aWord, + nsTArray<nsString>& aSuggestions) { + if (NS_WARN_IF(!mConverter)) { + return NS_ERROR_NOT_INITIALIZED; + } + + int32_t selOffset; + nsresult result; + result = SetupDoc(&selOffset); + if (NS_FAILED(result)) return result; + + bool done; + while (NS_SUCCEEDED(mTextServicesDocument->IsDone(&done)) && !done) { + int32_t begin, end; + nsAutoString str; + mTextServicesDocument->GetCurrentTextBlock(str); + while (mConverter->FindNextWord(str, selOffset, &begin, &end)) { + const nsDependentSubstring currWord(str, begin, end - begin); + bool isMisspelled; + result = CheckWord(currWord, &isMisspelled, &aSuggestions); + if (NS_WARN_IF(NS_FAILED(result))) { + return result; + } + if (isMisspelled) { + aWord = currWord; + MOZ_KnownLive(mTextServicesDocument) + ->SetSelection(AssertedCast<uint32_t>(begin), + AssertedCast<uint32_t>(end - begin)); + // After ScrollSelectionIntoView(), the pending notifications might + // be flushed and PresShell/PresContext/Frames may be dead. + // See bug 418470. + mTextServicesDocument->ScrollSelectionIntoView(); + return NS_OK; + } + selOffset = end; + } + mTextServicesDocument->NextBlock(); + selOffset = 0; + } + return NS_OK; +} + +RefPtr<mozilla::CheckWordPromise> mozSpellChecker::CheckWords( + const nsTArray<nsString>& aWords) { + if (XRE_IsContentProcess()) { + return mEngine->CheckWords(aWords); + } + + nsTArray<bool> misspells; + misspells.SetCapacity(aWords.Length()); + for (auto& word : aWords) { + bool misspelled; + nsresult rv = CheckWord(word, &misspelled, nullptr); + if (NS_WARN_IF(NS_FAILED(rv))) { + return mozilla::CheckWordPromise::CreateAndReject(rv, __func__); + } + misspells.AppendElement(misspelled); + } + return mozilla::CheckWordPromise::CreateAndResolve(std::move(misspells), + __func__); +} + +nsresult mozSpellChecker::CheckWord(const nsAString& aWord, bool* aIsMisspelled, + nsTArray<nsString>* aSuggestions) { + if (XRE_IsContentProcess()) { + // Use async version (CheckWords or Suggest) on content process + return NS_ERROR_FAILURE; + } + + nsresult result; + bool correct; + + if (!mSpellCheckingEngine) { + return NS_ERROR_NULL_POINTER; + } + *aIsMisspelled = false; + result = mSpellCheckingEngine->Check(aWord, &correct); + NS_ENSURE_SUCCESS(result, result); + if (!correct) { + if (aSuggestions) { + result = mSpellCheckingEngine->Suggest(aWord, *aSuggestions); + NS_ENSURE_SUCCESS(result, result); + } + *aIsMisspelled = true; + } + return NS_OK; +} + +RefPtr<mozilla::SuggestionsPromise> mozSpellChecker::Suggest( + const nsAString& aWord, uint32_t aMaxCount) { + if (XRE_IsContentProcess()) { + return mEngine->SendSuggest(aWord, aMaxCount) + ->Then( + mozilla::GetCurrentSerialEventTarget(), __func__, + [](nsTArray<nsString>&& aSuggestions) { + return mozilla::SuggestionsPromise::CreateAndResolve( + std::move(aSuggestions), __func__); + }, + [](mozilla::ipc::ResponseRejectReason&& aReason) { + return mozilla::SuggestionsPromise::CreateAndReject( + NS_ERROR_NOT_AVAILABLE, __func__); + }); + } + + if (!mSpellCheckingEngine) { + return mozilla::SuggestionsPromise::CreateAndReject(NS_ERROR_NOT_AVAILABLE, + __func__); + } + + bool correct; + nsresult rv = mSpellCheckingEngine->Check(aWord, &correct); + if (NS_FAILED(rv)) { + return mozilla::SuggestionsPromise::CreateAndReject(rv, __func__); + } + nsTArray<nsString> suggestions; + if (!correct) { + rv = mSpellCheckingEngine->Suggest(aWord, suggestions); + if (NS_FAILED(rv)) { + return mozilla::SuggestionsPromise::CreateAndReject(rv, __func__); + } + if (suggestions.Length() > aMaxCount) { + suggestions.TruncateLength(aMaxCount); + } + } + return mozilla::SuggestionsPromise::CreateAndResolve(std::move(suggestions), + __func__); +} + +nsresult mozSpellChecker::Replace(const nsAString& aOldWord, + const nsAString& aNewWord, + bool aAllOccurrences) { + if (NS_WARN_IF(!mConverter)) { + return NS_ERROR_NOT_INITIALIZED; + } + + if (!aAllOccurrences) { + MOZ_KnownLive(mTextServicesDocument)->InsertText(aNewWord); + return NS_OK; + } + + int32_t selOffset; + int32_t startBlock; + int32_t begin, end; + bool done; + nsresult result; + + // find out where we are + result = SetupDoc(&selOffset); + if (NS_WARN_IF(NS_FAILED(result))) { + return result; + } + result = GetCurrentBlockIndex(mTextServicesDocument, &startBlock); + if (NS_WARN_IF(NS_FAILED(result))) { + return result; + } + + // start at the beginning + result = mTextServicesDocument->FirstBlock(); + if (NS_WARN_IF(NS_FAILED(result))) { + return result; + } + int32_t currOffset = 0; + int32_t currentBlock = 0; + int32_t wordLengthDifference = + AssertedCast<int32_t>(static_cast<int64_t>(aNewWord.Length()) - + static_cast<int64_t>(aOldWord.Length())); + while (NS_SUCCEEDED(mTextServicesDocument->IsDone(&done)) && !done) { + nsAutoString str; + mTextServicesDocument->GetCurrentTextBlock(str); + while (mConverter->FindNextWord(str, currOffset, &begin, &end)) { + if (aOldWord.Equals(Substring(str, begin, end - begin))) { + // if we are before the current selection point but in the same + // block move the selection point forwards + if (currentBlock == startBlock && begin < selOffset) { + selOffset += wordLengthDifference; + if (selOffset < begin) { + selOffset = begin; + } + } + // Don't keep running if selecting or inserting text fails because + // it may cause infinite loop. + if (NS_WARN_IF(NS_FAILED( + MOZ_KnownLive(mTextServicesDocument) + ->SetSelection(AssertedCast<uint32_t>(begin), + AssertedCast<uint32_t>(end - begin))))) { + return NS_ERROR_FAILURE; + } + if (NS_WARN_IF(NS_FAILED( + MOZ_KnownLive(mTextServicesDocument)->InsertText(aNewWord)))) { + return NS_ERROR_FAILURE; + } + mTextServicesDocument->GetCurrentTextBlock(str); + end += wordLengthDifference; // recursion was cute in GEB, not here. + } + currOffset = end; + } + mTextServicesDocument->NextBlock(); + currentBlock++; + currOffset = 0; + } + + // We are done replacing. Put the selection point back where we found it + // (or equivalent); + result = mTextServicesDocument->FirstBlock(); + if (NS_WARN_IF(NS_FAILED(result))) { + return result; + } + currentBlock = 0; + while (NS_SUCCEEDED(mTextServicesDocument->IsDone(&done)) && !done && + currentBlock < startBlock) { + mTextServicesDocument->NextBlock(); + } + + // After we have moved to the block where the first occurrence of replace + // was done, put the selection to the next word following it. In case there + // is no word following it i.e if it happens to be the last word in that + // block, then move to the next block and put the selection to the first + // word in that block, otherwise when the Setupdoc() is called, it queries + // the LastSelectedBlock() and the selection offset of the last occurrence + // of the replaced word is taken instead of the first occurrence and things + // get messed up as reported in the bug 244969 + + if (NS_SUCCEEDED(mTextServicesDocument->IsDone(&done)) && !done) { + nsAutoString str; + mTextServicesDocument->GetCurrentTextBlock(str); + if (mConverter->FindNextWord(str, selOffset, &begin, &end)) { + MOZ_KnownLive(mTextServicesDocument) + ->SetSelection(AssertedCast<uint32_t>(begin), 0); + return NS_OK; + } + mTextServicesDocument->NextBlock(); + mTextServicesDocument->GetCurrentTextBlock(str); + if (mConverter->FindNextWord(str, 0, &begin, &end)) { + MOZ_KnownLive(mTextServicesDocument) + ->SetSelection(AssertedCast<uint32_t>(begin), 0); + } + } + return NS_OK; +} + +nsresult mozSpellChecker::IgnoreAll(const nsAString& aWord) { + if (mPersonalDictionary) { + mPersonalDictionary->IgnoreWord(aWord); + } + return NS_OK; +} + +nsresult mozSpellChecker::AddWordToPersonalDictionary(const nsAString& aWord) { + nsresult res; + if (NS_WARN_IF(!mPersonalDictionary)) { + return NS_ERROR_NOT_INITIALIZED; + } + res = mPersonalDictionary->AddWord(aWord); + return res; +} + +nsresult mozSpellChecker::RemoveWordFromPersonalDictionary( + const nsAString& aWord) { + nsresult res; + if (NS_WARN_IF(!mPersonalDictionary)) { + return NS_ERROR_NOT_INITIALIZED; + } + res = mPersonalDictionary->RemoveWord(aWord); + return res; +} + +nsresult mozSpellChecker::GetPersonalDictionary(nsTArray<nsString>* aWordList) { + if (!aWordList || !mPersonalDictionary) return NS_ERROR_NULL_POINTER; + + nsCOMPtr<nsIStringEnumerator> words; + mPersonalDictionary->GetWordList(getter_AddRefs(words)); + + bool hasMore; + nsAutoString word; + while (NS_SUCCEEDED(words->HasMore(&hasMore)) && hasMore) { + words->GetNext(word); + aWordList->AppendElement(word); + } + return NS_OK; +} + +nsresult mozSpellChecker::GetDictionaryList( + nsTArray<nsCString>* aDictionaryList) { + MOZ_ASSERT(aDictionaryList->IsEmpty()); + if (XRE_IsContentProcess()) { + ContentChild* child = ContentChild::GetSingleton(); + child->GetAvailableDictionaries(*aDictionaryList); + return NS_OK; + } + + nsresult rv; + + // For catching duplicates + nsTHashSet<nsCString> dictionaries; + + nsCOMArray<mozISpellCheckingEngine> spellCheckingEngines; + rv = GetEngineList(&spellCheckingEngines); + NS_ENSURE_SUCCESS(rv, rv); + + for (int32_t i = 0; i < spellCheckingEngines.Count(); i++) { + nsCOMPtr<mozISpellCheckingEngine> engine = spellCheckingEngines[i]; + + nsTArray<nsCString> dictNames; + engine->GetDictionaryList(dictNames); + for (auto& dictName : dictNames) { + // Skip duplicate dictionaries. Only take the first one + // for each name. + if (!dictionaries.EnsureInserted(dictName)) continue; + + aDictionaryList->AppendElement(dictName); + } + } + + return NS_OK; +} + +nsresult mozSpellChecker::GetCurrentDictionaries( + nsTArray<nsCString>& aDictionaries) { + if (XRE_IsContentProcess()) { + aDictionaries = mCurrentDictionaries.Clone(); + return NS_OK; + } + + if (!mSpellCheckingEngine) { + aDictionaries.Clear(); + return NS_OK; + } + + return mSpellCheckingEngine->GetDictionaries(aDictionaries); +} + +nsresult mozSpellChecker::SetCurrentDictionary(const nsACString& aDictionary) { + if (XRE_IsContentProcess()) { + mCurrentDictionaries.Clear(); + bool isSuccess; + mEngine->SendSetDictionary(aDictionary, &isSuccess); + if (!isSuccess) { + return NS_ERROR_NOT_AVAILABLE; + } + + mCurrentDictionaries.AppendElement(aDictionary); + return NS_OK; + } + + // Calls to mozISpellCheckingEngine::SetDictionary might destroy us + RefPtr<mozSpellChecker> kungFuDeathGrip = this; + + mSpellCheckingEngine = nullptr; + + if (aDictionary.IsEmpty()) { + return NS_OK; + } + + nsresult rv; + nsCOMArray<mozISpellCheckingEngine> spellCheckingEngines; + rv = GetEngineList(&spellCheckingEngines); + NS_ENSURE_SUCCESS(rv, rv); + + nsTArray<nsCString> dictionaries; + dictionaries.AppendElement(aDictionary); + for (int32_t i = 0; i < spellCheckingEngines.Count(); i++) { + // We must set mSpellCheckingEngine before we call SetDictionaries, since + // SetDictionaries calls back to this spell checker to check if the + // dictionary was set + mSpellCheckingEngine = spellCheckingEngines[i]; + rv = mSpellCheckingEngine->SetDictionaries(dictionaries); + + if (NS_SUCCEEDED(rv)) { + nsCOMPtr<mozIPersonalDictionary> personalDictionary = + do_GetService("@mozilla.org/spellchecker/personaldictionary;1"); + mSpellCheckingEngine->SetPersonalDictionary(personalDictionary); + + mConverter = new mozEnglishWordUtils; + return NS_OK; + } + } + + mSpellCheckingEngine = nullptr; + + // We could not find any engine with the requested dictionary + return NS_ERROR_NOT_AVAILABLE; +} + +RefPtr<GenericPromise> mozSpellChecker::SetCurrentDictionaries( + const nsTArray<nsCString>& aDictionaries) { + if (XRE_IsContentProcess()) { + if (!mEngine) { + mCurrentDictionaries.Clear(); + return GenericPromise::CreateAndReject(NS_ERROR_NOT_AVAILABLE, __func__); + } + + // mCurrentDictionaries will be set by RemoteSpellCheckEngineChild + return mEngine->SetCurrentDictionaries(aDictionaries); + } + + // Calls to mozISpellCheckingEngine::SetDictionary might destroy us + RefPtr<mozSpellChecker> kungFuDeathGrip = this; + + mSpellCheckingEngine = nullptr; + + if (aDictionaries.IsEmpty()) { + return GenericPromise::CreateAndResolve(true, __func__); + } + + nsresult rv; + nsCOMArray<mozISpellCheckingEngine> spellCheckingEngines; + rv = GetEngineList(&spellCheckingEngines); + if (NS_FAILED(rv)) { + return GenericPromise::CreateAndReject(rv, __func__); + } + + for (int32_t i = 0; i < spellCheckingEngines.Count(); i++) { + // We must set mSpellCheckingEngine before we call SetDictionaries, since + // SetDictionaries calls back to this spell checker to check if the + // dictionary was set + mSpellCheckingEngine = spellCheckingEngines[i]; + rv = mSpellCheckingEngine->SetDictionaries(aDictionaries); + + if (NS_SUCCEEDED(rv)) { + mCurrentDictionaries = aDictionaries.Clone(); + + nsCOMPtr<mozIPersonalDictionary> personalDictionary = + do_GetService("@mozilla.org/spellchecker/personaldictionary;1"); + mSpellCheckingEngine->SetPersonalDictionary(personalDictionary); + + mConverter = new mozEnglishWordUtils; + return GenericPromise::CreateAndResolve(true, __func__); + } + } + + mSpellCheckingEngine = nullptr; + + // We could not find any engine with the requested dictionary + return GenericPromise::CreateAndReject(NS_ERROR_NOT_AVAILABLE, __func__); +} + +RefPtr<GenericPromise> mozSpellChecker::SetCurrentDictionaryFromList( + const nsTArray<nsCString>& aList) { + if (aList.IsEmpty()) { + return GenericPromise::CreateAndReject(NS_ERROR_INVALID_ARG, __func__); + } + + if (XRE_IsContentProcess()) { + if (!mEngine) { + mCurrentDictionaries.Clear(); + return GenericPromise::CreateAndReject(NS_ERROR_NOT_AVAILABLE, __func__); + } + + // mCurrentDictionaries will be set by RemoteSpellCheckEngineChild + return mEngine->SetCurrentDictionaryFromList(aList); + } + + for (auto& dictionary : aList) { + nsresult rv = SetCurrentDictionary(dictionary); + if (NS_SUCCEEDED(rv)) { + return GenericPromise::CreateAndResolve(true, __func__); + } + } + // We could not find any engine with the requested dictionary + return GenericPromise::CreateAndReject(NS_ERROR_NOT_AVAILABLE, __func__); +} + +nsresult mozSpellChecker::SetupDoc(int32_t* outBlockOffset) { + nsresult rv; + + TextServicesDocument::BlockSelectionStatus blockStatus; + *outBlockOffset = 0; + + if (!mFromStart) { + uint32_t selOffset, selLength; + rv = MOZ_KnownLive(mTextServicesDocument) + ->LastSelectedBlock(&blockStatus, &selOffset, &selLength); + if (NS_SUCCEEDED(rv) && + blockStatus != + TextServicesDocument::BlockSelectionStatus::eBlockNotFound) { + switch (blockStatus) { + // No TB in S, but found one before/after S. + case TextServicesDocument::BlockSelectionStatus::eBlockOutside: + // S begins or ends in TB but extends outside of TB. + case TextServicesDocument::BlockSelectionStatus::eBlockPartial: + // the TS doc points to the block we want. + if (NS_WARN_IF(selOffset == UINT32_MAX) || + NS_WARN_IF(selLength == UINT32_MAX)) { + rv = mTextServicesDocument->FirstBlock(); + *outBlockOffset = 0; + break; + } + *outBlockOffset = AssertedCast<int32_t>(selOffset + selLength); + break; + + // S extends beyond the start and end of TB. + case TextServicesDocument::BlockSelectionStatus::eBlockInside: + // we want the block after this one. + rv = mTextServicesDocument->NextBlock(); + *outBlockOffset = 0; + break; + + // TB contains entire S. + case TextServicesDocument::BlockSelectionStatus::eBlockContains: + if (NS_WARN_IF(selOffset == UINT32_MAX) || + NS_WARN_IF(selLength == UINT32_MAX)) { + rv = mTextServicesDocument->FirstBlock(); + *outBlockOffset = 0; + break; + } + *outBlockOffset = AssertedCast<int32_t>(selOffset + selLength); + break; + + // There is no text block (TB) in or before the selection (S). + case TextServicesDocument::BlockSelectionStatus::eBlockNotFound: + default: + MOZ_ASSERT_UNREACHABLE("Shouldn't ever get this status"); + } + } + // Failed to get last sel block. Just start at beginning + else { + rv = mTextServicesDocument->FirstBlock(); + *outBlockOffset = 0; + } + + } + // We want the first block + else { + rv = mTextServicesDocument->FirstBlock(); + mFromStart = false; + } + return rv; +} + +// utility method to discover which block we're in. The TSDoc interface doesn't +// give us this, because it can't assume a read-only document. shamelessly +// stolen from nsTextServicesDocument +nsresult mozSpellChecker::GetCurrentBlockIndex( + TextServicesDocument* aTextServicesDocument, int32_t* aOutBlockIndex) { + int32_t blockIndex = 0; + bool isDone = false; + nsresult result = NS_OK; + + do { + aTextServicesDocument->PrevBlock(); + result = aTextServicesDocument->IsDone(&isDone); + if (!isDone) { + blockIndex++; + } + } while (NS_SUCCEEDED(result) && !isDone); + + *aOutBlockIndex = blockIndex; + + return result; +} + +nsresult mozSpellChecker::GetEngineList( + nsCOMArray<mozISpellCheckingEngine>* aSpellCheckingEngines) { + MOZ_ASSERT(!XRE_IsContentProcess()); + + nsresult rv; + bool hasMoreEngines; + + nsCOMPtr<nsICategoryManager> catMgr = + do_GetService(NS_CATEGORYMANAGER_CONTRACTID); + if (!catMgr) return NS_ERROR_NULL_POINTER; + + nsCOMPtr<nsISimpleEnumerator> catEntries; + + // Get contract IDs of registrated external spell-check engines and + // append one of HunSpell at the end. + rv = catMgr->EnumerateCategory("spell-check-engine", + getter_AddRefs(catEntries)); + if (NS_FAILED(rv)) return rv; + + while (NS_SUCCEEDED(catEntries->HasMoreElements(&hasMoreEngines)) && + hasMoreEngines) { + nsCOMPtr<nsISupports> elem; + rv = catEntries->GetNext(getter_AddRefs(elem)); + + nsCOMPtr<nsISupportsCString> entry = do_QueryInterface(elem, &rv); + if (NS_FAILED(rv)) return rv; + + nsCString contractId; + rv = entry->GetData(contractId); + if (NS_FAILED(rv)) return rv; + + // Try to load spellchecker engine. Ignore errors silently + // except for the last one (HunSpell). + nsCOMPtr<mozISpellCheckingEngine> engine = + do_GetService(contractId.get(), &rv); + if (NS_SUCCEEDED(rv)) { + aSpellCheckingEngines->AppendObject(engine); + } + } + + // Try to load HunSpell spellchecker engine. + nsCOMPtr<mozISpellCheckingEngine> engine = + do_GetService(DEFAULT_SPELL_CHECKER, &rv); + if (NS_FAILED(rv)) { + // Fail if not succeeded to load HunSpell. Ignore errors + // for external spellcheck engines. + return rv; + } + aSpellCheckingEngines->AppendObject(engine); + + return NS_OK; +} diff --git a/extensions/spellcheck/src/mozSpellChecker.h b/extensions/spellcheck/src/mozSpellChecker.h new file mode 100644 index 0000000000..aec4a95d65 --- /dev/null +++ b/extensions/spellcheck/src/mozSpellChecker.h @@ -0,0 +1,197 @@ +/* -*- 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 mozSpellChecker_h__ +#define mozSpellChecker_h__ + +#include "mozilla/MozPromise.h" +#include "nsCOMPtr.h" +#include "nsCOMArray.h" +#include "nsString.h" +#include "mozIPersonalDictionary.h" +#include "mozISpellCheckingEngine.h" +#include "nsClassHashtable.h" +#include "nsTArray.h" +#include "nsCycleCollectionParticipant.h" + +class mozEnglishWordUtils; + +namespace mozilla { +class RemoteSpellcheckEngineChild; +class TextServicesDocument; +typedef MozPromise<CopyableTArray<bool>, nsresult, false> CheckWordPromise; +typedef MozPromise<CopyableTArray<nsString>, nsresult, false> + SuggestionsPromise; +} // namespace mozilla + +class mozSpellChecker final { + public: + NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(mozSpellChecker) + NS_DECL_CYCLE_COLLECTION_NATIVE_CLASS(mozSpellChecker) + + static already_AddRefed<mozSpellChecker> Create() { + RefPtr<mozSpellChecker> spellChecker = new mozSpellChecker(); + nsresult rv = spellChecker->Init(); + NS_ENSURE_SUCCESS(rv, nullptr); + return spellChecker.forget(); + } + + /** + * Tells the spellchecker what document to check. + * @param aDoc is the document to check. + * @param aFromStartOfDoc If true, start check from beginning of document, + * if false, start check from current cursor position. + */ + nsresult SetDocument(mozilla::TextServicesDocument* aTextServicesDocument, + bool aFromStartofDoc); + + /** + * Selects (hilites) the next misspelled word in the document. + * @param aWord will contain the misspelled word. + * @param aSuggestions is an array of nsStrings, that represent the + * suggested replacements for the misspelled word. + */ + MOZ_CAN_RUN_SCRIPT + nsresult NextMisspelledWord(nsAString& aWord, + nsTArray<nsString>& aSuggestions); + + /** + * Checks if a word is misspelled. No document is required to use this method. + * @param aWord is the word to check. + * @param aIsMisspelled will be set to true if the word is misspelled. + * @param aSuggestions is an array of nsStrings which represent the + * suggested replacements for the misspelled word. The array will be empty + * in chrome process if there aren't any suggestions. If suggestions is + * unnecessary, use CheckWords of async version. + */ + nsresult CheckWord(const nsAString& aWord, bool* aIsMisspelled, + nsTArray<nsString>* aSuggestions); + + /** + * This is a flavor of CheckWord, is async version of CheckWord. + * @Param aWords is array of words to check + */ + RefPtr<mozilla::CheckWordPromise> CheckWords( + const nsTArray<nsString>& aWords); + + /* + * Checks if a word is misspelled, then get suggestion words if existed. + */ + RefPtr<mozilla::SuggestionsPromise> Suggest(const nsAString& aWord, + uint32_t aMaxCount); + + /** + * Replaces the old word with the specified new word. + * @param aOldWord is the word to be replaced. + * @param aNewWord is the word that is to replace old word. + * @param aAllOccurrences will replace all occurrences of old + * word, in the document, with new word when it is true. If + * false, it will replace the 1st occurrence only! + */ + MOZ_CAN_RUN_SCRIPT + nsresult Replace(const nsAString& aOldWord, const nsAString& aNewWord, + bool aAllOccurrences); + + /** + * Ignores all occurrences of the specified word in the document. + * @param aWord is the word to ignore. + */ + nsresult IgnoreAll(const nsAString& aWord); + + /** + * Add a word to the user's personal dictionary. + * @param aWord is the word to add. + */ + nsresult AddWordToPersonalDictionary(const nsAString& aWord); + + /** + * Remove a word from the user's personal dictionary. + * @param aWord is the word to remove. + */ + nsresult RemoveWordFromPersonalDictionary(const nsAString& aWord); + + /** + * Returns the list of words in the user's personal dictionary. + * @param aWordList is an array of nsStrings that represent the + * list of words in the user's personal dictionary. + */ + nsresult GetPersonalDictionary(nsTArray<nsString>* aWordList); + + /** + * Returns the list of strings representing the dictionaries + * the spellchecker supports. It was suggested that the strings + * returned be in the RFC 1766 format. This format looks something + * like <ISO 639 language code>-<ISO 3166 country code>. + * For example: en-US + * @param aDictionaryList is an array of nsStrings that represent the + * dictionaries supported by the spellchecker. + */ + nsresult GetDictionaryList(nsTArray<nsCString>* aDictionaryList); + + /** + * Returns a string representing the current dictionaries. + * @param aDictionaries will contain the names of the dictionaries. + * This name is the same string that is in the list returned + * by GetDictionaryList(). + */ + nsresult GetCurrentDictionaries(nsTArray<nsCString>& aDictionaries); + + /** + * Tells the spellchecker to use the specified dictionary. + * @param aDictionary a string that is in the list returned + * by GetDictionaryList() or an empty string . If aDictionary is + * an empty array, the spellchecker will be disabled. + */ + nsresult SetCurrentDictionary(const nsACString& aDictionary); + + /** + * Tells the spellchecker to use the specified dictionaries. + * @param aDictionaries an array of strings that is in the list returned + * by GetDictionaryList() or an empty array. If aDictionaries is + * an empty array, the spellchecker will be disabled. + */ + RefPtr<mozilla::GenericPromise> SetCurrentDictionaries( + const nsTArray<nsCString>& aDictionaries); + + /** + * Tells the spellchecker to use a specific dictionary from list. + * @param aList a preferred dictionary list + */ + RefPtr<mozilla::GenericPromise> SetCurrentDictionaryFromList( + const nsTArray<nsCString>& aList); + + void DeleteRemoteEngine() { mEngine = nullptr; } + + mozilla::TextServicesDocument* GetTextServicesDocument(); + + protected: + mozSpellChecker(); + virtual ~mozSpellChecker(); + + nsresult Init(); + + RefPtr<mozEnglishWordUtils> mConverter; + RefPtr<mozilla::TextServicesDocument> mTextServicesDocument; + nsCOMPtr<mozIPersonalDictionary> mPersonalDictionary; + + nsCOMPtr<mozISpellCheckingEngine> mSpellCheckingEngine; + bool mFromStart; + + nsTArray<nsCString> mCurrentDictionaries; + + MOZ_CAN_RUN_SCRIPT + nsresult SetupDoc(int32_t* outBlockOffset); + + nsresult GetCurrentBlockIndex( + mozilla::TextServicesDocument* aTextServicesDocument, + int32_t* aOutBlockIndex); + + nsresult GetEngineList(nsCOMArray<mozISpellCheckingEngine>* aDictionaryList); + + mozilla::RemoteSpellcheckEngineChild* mEngine; + + friend class mozilla::RemoteSpellcheckEngineChild; +}; +#endif // mozSpellChecker_h__ |