Bug 1467796 - part 3: Make mozInlineSpellChecker::ReplaceWord() use TextEditor::ReplaceTextAsAction() r?m_kato, smaug draft
authorMasayuki Nakano <masayuki@d-toybox.com>
Wed, 04 Jul 2018 22:51:55 +0900
changeset 825298 4358040a76d39102864e8ad2d6e5e2c1239ceac0
parent 825297 99a46673d020e1b9ec361c315dfc78748b1592b4
push id118070
push usermasayuki@d-toybox.com
push dateWed, 01 Aug 2018 12:37:38 +0000
reviewersm_kato, smaug
bugs1467796
milestone63.0a1
Bug 1467796 - part 3: Make mozInlineSpellChecker::ReplaceWord() use TextEditor::ReplaceTextAsAction() r?m_kato, smaug mozInlineSpellChecker::ReplaceWord() is used for replacing misspelled word with a word. So, this is necessary to be distinguished from insertText command when we implement InputEvent.inputType. So, we should make it use TextEditor::ReplaceTextAsAction() instead (same as autocomplete). This patch makes TextEditor::ReplaceTextAsAction() take optional argument to make callers can specify replace range. Then, the range is a spellchecker selection range if the caller is mozInlineSpellChecker::ReplaceWord(). Prior to this patch, it clones the range for normal selection, but it's expensive and we may be able to reuse cached range of Selection in this case. So, this patch makes Selection::AddRangeInternal() checks if given range is in another Selection and use mCachedRange as far as possible. MozReview-Commit-ID: JIOTTsxlj4Q
dom/base/Selection.cpp
dom/base/nsRange.h
editor/libeditor/TextEditor.cpp
editor/libeditor/TextEditor.h
editor/libeditor/tests/mochitest.ini
editor/libeditor/tests/test_undo_after_spellchecker_replaces_word.html
extensions/spellcheck/src/mozInlineSpellChecker.cpp
extensions/spellcheck/tests/mochitest/test_bug1170484.html
extensions/spellcheck/tests/mochitest/test_bug1272623.html
--- a/dom/base/Selection.cpp
+++ b/dom/base/Selection.cpp
@@ -2121,17 +2121,37 @@ Selection::AddRange(nsRange& aRange, Err
 {
   return AddRangeInternal(aRange, GetParentObject(), aRv);
 }
 
 void
 Selection::AddRangeInternal(nsRange& aRange, nsIDocument* aDocument,
                             ErrorResult& aRv)
 {
-  nsINode* rangeRoot = aRange.GetRoot();
+  // If the given range is part of another Selection, we need to clone the
+  // range first.
+  RefPtr<nsRange> range;
+  if (aRange.IsInSelection() && aRange.GetSelection() != this) {
+    // Because of performance reason, when there is a cached range, let's use
+    // it.  Otherwise, clone the range.
+    if (mCachedRange) {
+      range = std::move(mCachedRange);
+      nsresult rv = range->SetStartAndEnd(aRange.StartRef().AsRaw(),
+                                          aRange.EndRef().AsRaw());
+      if (NS_WARN_IF(NS_FAILED(rv))) {
+        return;
+      }
+    } else {
+      range = aRange.CloneRange();
+    }
+  } else {
+    range = &aRange;
+  }
+
+  nsINode* rangeRoot = range->GetRoot();
   if (aDocument != rangeRoot && (!rangeRoot ||
                                  aDocument != rangeRoot->GetComposedDoc())) {
     // http://w3c.github.io/selection-api/#dom-selection-addrange
     // "...  if the root of the range's boundary points are the document
     // associated with context object. Otherwise, this method must do nothing."
     return;
   }
 
@@ -2141,24 +2161,24 @@ Selection::AddRangeInternal(nsRange& aRa
 
   // AddTableCellRange might flush frame.
   RefPtr<Selection> kungFuDeathGrip(this);
 
   // This inserts a table cell range in proper document order
   // and returns NS_OK if range doesn't contain just one table cell
   bool didAddRange;
   int32_t rangeIndex;
-  nsresult result = AddTableCellRange(&aRange, &didAddRange, &rangeIndex);
+  nsresult result = AddTableCellRange(range, &didAddRange, &rangeIndex);
   if (NS_FAILED(result)) {
     aRv.Throw(result);
     return;
   }
 
   if (!didAddRange) {
-    result = AddItem(&aRange, &rangeIndex);
+    result = AddItem(range, &rangeIndex);
     if (NS_FAILED(result)) {
       aRv.Throw(result);
       return;
     }
   }
 
   if (rangeIndex < 0) {
     return;
@@ -2167,17 +2187,17 @@ Selection::AddRangeInternal(nsRange& aRa
   SetAnchorFocusRange(rangeIndex);
 
   // Make sure the caret appears on the next line, if at a newline
   if (mSelectionType == SelectionType::eNormal) {
     SetInterlinePosition(true, IgnoreErrors());
   }
 
   RefPtr<nsPresContext>  presContext = GetPresContext();
-  SelectFrames(presContext, &aRange, true);
+  SelectFrames(presContext, range, true);
 
   if (!mFrameSelection)
     return;//nothing to do
 
   // Be aware, this instance may be destroyed after this call.
   // XXX Why doesn't this call Selection::NotifySelectionListener() directly?
   RefPtr<nsFrameSelection> frameSelection = mFrameSelection;
   result = frameSelection->NotifySelectionListeners(GetType());
--- a/dom/base/nsRange.h
+++ b/dom/base/nsRange.h
@@ -130,16 +130,21 @@ public:
   }
 
   /**
    * Called when the range is added/removed from a Selection.
    */
   void SetSelection(mozilla::dom::Selection* aSelection);
 
   /**
+   * Returns pointer to a Selection if the range is associated with a Selection.
+   */
+  mozilla::dom::Selection* GetSelection() const { return mSelection; }
+
+  /**
    * Return true if this range was generated.
    * @see SetIsGenerated
    */
   bool IsGenerated() const
   {
     return mIsGenerated;
   }
 
--- a/editor/libeditor/TextEditor.cpp
+++ b/editor/libeditor/TextEditor.cpp
@@ -1140,26 +1140,61 @@ TextEditor::SetText(const nsAString& aSt
   nsresult rv = SetTextAsSubAction(aString);
   if (NS_WARN_IF(NS_FAILED(rv))) {
     return rv;
   }
   return NS_OK;
 }
 
 nsresult
-TextEditor::ReplaceTextAsAction(const nsAString& aString)
+TextEditor::ReplaceTextAsAction(const nsAString& aString,
+                                nsRange* aReplaceRange /* = nullptr */)
 {
   AutoPlaceholderBatch batch(this, nullptr);
 
   // This should emulates inserting text for better undo/redo behavior.
   AutoTopLevelEditSubActionNotifier maybeTopLevelEditSubAction(
                                       *this, EditSubAction::eInsertText,
                                       nsIEditor::eNext);
 
-  nsresult rv = SetTextAsSubAction(aString);
+  if (!aReplaceRange) {
+    nsresult rv = SetTextAsSubAction(aString);
+    if (NS_WARN_IF(NS_FAILED(rv))) {
+      return rv;
+    }
+    return NS_OK;
+  }
+
+  if (NS_WARN_IF(aString.IsEmpty() && aReplaceRange->Collapsed())) {
+    return NS_OK;
+  }
+
+  // Note that do not notify selectionchange caused by selecting all text
+  // because it's preparation of our delete implementation so web apps
+  // shouldn't receive such selectionchange before the first mutation.
+  AutoUpdateViewBatch preventSelectionChangeEvent(this);
+
+  RefPtr<Selection> selection = GetSelection();
+  if (NS_WARN_IF(!selection)) {
+    return NS_ERROR_FAILURE;
+  }
+
+  // Select the range but as far as possible, we should not create new range
+  // even if it's part of special Selection.
+  nsresult rv = selection->RemoveAllRangesTemporarily();
+  if (NS_WARN_IF(NS_FAILED(rv))) {
+    return rv;
+  }
+  ErrorResult error;
+  selection->AddRange(*aReplaceRange, error);
+  if (NS_WARN_IF(error.Failed())) {
+    return error.StealNSResult();
+  }
+
+  rv = ReplaceSelectionAsSubAction(aString);
   if (NS_WARN_IF(NS_FAILED(rv))) {
     return rv;
   }
   return NS_OK;
 }
 
 nsresult
 TextEditor::SetTextAsSubAction(const nsAString& aString)
@@ -1211,29 +1246,43 @@ TextEditor::SetTextAsSubAction(const nsA
       if (NS_WARN_IF(!rootElement)) {
         return NS_ERROR_FAILURE;
       }
       rv = selection->Collapse(rootElement, 0);
     } else {
       rv = EditorBase::SelectEntireDocument(selection);
     }
     if (NS_SUCCEEDED(rv)) {
-      if (aString.IsEmpty()) {
-        rv = DeleteSelectionAsSubAction(eNone, eStrip);
-        NS_WARNING_ASSERTION(NS_FAILED(rv), "Failed to remove all text");
-      } else {
-        rv = InsertTextAsSubAction(aString);
-        NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "Failed to insert the new text");
-      }
+      rv = ReplaceSelectionAsSubAction(aString);
+      NS_WARNING_ASSERTION(NS_SUCCEEDED(rv),
+        "Failed to replace selection with new string");
     }
   }
   // post-process
   return rules->DidDoAction(selection, subActionInfo, rv);
 }
 
+nsresult
+TextEditor::ReplaceSelectionAsSubAction(const nsAString& aString)
+{
+  if (aString.IsEmpty()) {
+    nsresult rv = DeleteSelectionAsSubAction(eNone, eStrip);
+    if (NS_WARN_IF(NS_FAILED(rv))) {
+      return rv;
+    }
+    return NS_OK;
+  }
+
+  nsresult rv = InsertTextAsSubAction(aString);
+  if (NS_WARN_IF(NS_FAILED(rv))) {
+    return rv;
+  }
+  return NS_OK;
+}
+
 bool
 TextEditor::EnsureComposition(WidgetCompositionEvent& aCompositionEvent)
 {
   if (mComposition) {
     return true;
   }
   // The compositionstart event must cause creating new TextComposition
   // instance at being dispatched by IMEStateManager.
--- a/editor/libeditor/TextEditor.h
+++ b/editor/libeditor/TextEditor.h
@@ -156,22 +156,25 @@ public:
    * Replace existed string with a string.
    * This is fast path to replace all string when using single line control.
    *
    * @ param aString   the string to be set
    */
   nsresult SetText(const nsAString& aString);
 
   /**
-   * Replace all text in this editor with aString and treat the change as
-   * inserting the string.
+   * Replace text in aReplaceRange or all text in this editor with aString and
+   * treat the change as inserting the string.
    *
-   * @param aString    The string to set.
+   * @param aString             The string to set.
+   * @param aReplaceRange       The range to be replaced.
+   *                            If nullptr, all contents will be replaced.
    */
-  nsresult ReplaceTextAsAction(const nsAString& aString);
+  nsresult ReplaceTextAsAction(const nsAString& aString,
+                               nsRange* aReplaceRange = nullptr);
 
   /**
    * OnInputParagraphSeparator() is called when user tries to separate current
    * paragraph with Enter key press or something.
    */
   nsresult OnInputParagraphSeparator();
 
   /**
@@ -275,16 +278,23 @@ protected: // May be called by friends.
    * Replace existed string with aString.  Caller must guarantee that there
    * is a placeholder transaction which will have the transaction.
    *
    * @ param aString   The string to be set.
    */
   nsresult SetTextAsSubAction(const nsAString& aString);
 
   /**
+   * ReplaceSelectionAsSubAction() replaces selection with aString.
+   *
+   * @param aString    The string to replace.
+   */
+  nsresult ReplaceSelectionAsSubAction(const nsAString& aString);
+
+  /**
    * InsertBrElementWithTransaction() creates a <br> element and inserts it
    * before aPointToInsert.  Then, tries to collapse selection at or after the
    * new <br> node if aSelect is not eNone.
    *
    * @param aSelection          The selection of this editor.
    * @param aPointToInsert      The DOM point where should be <br> node inserted
    *                            before.
    * @param aSelect             If eNone, this won't change selection.
--- a/editor/libeditor/tests/mochitest.ini
+++ b/editor/libeditor/tests/mochitest.ini
@@ -279,16 +279,18 @@ subsuite = clipboard
 [test_keypress_untrusted_event.html]
 [test_middle_click_paste.html]
 subsuite = clipboard
 [test_objectResizing.html]
 [test_root_element_replacement.html]
 [test_select_all_without_body.html]
 [test_spellcheck_pref.html]
 skip-if = toolkit == 'android'
+[test_undo_after_spellchecker_replaces_word.html]
+skip-if = toolkit == 'android'
 [test_undo_redo_stack_after_setting_value.html]
 [test_backspace_vs.html]
 [test_css_chrome_load_access.html]
 skip-if = toolkit == 'android' # chrome urls not available due to packaging
 [test_selection_move_commands.html]
 [test_pasteImgTextarea.html]
 skip-if = toolkit == 'android' # bug 1299578
 [test_execCommandPaste_noTarget.html]
new file mode 100644
--- /dev/null
+++ b/editor/libeditor/tests/test_undo_after_spellchecker_replaces_word.html
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="application/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<div id="display"></div>
+<textarea id="textarea">abc abx abc</textarea>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="application/javascript">
+SimpleTest.waitForExplicitFinish();
+SimpleTest.waitForFocus(() => {
+  let textarea = document.getElementById("textarea");
+  let editor = SpecialPowers.wrap(textarea).editor;
+
+  let inlineSpellChecker = editor.getInlineSpellChecker(true);
+
+  textarea.focus();
+
+  SpecialPowers.Cu.import(
+    "resource://testing-common/AsyncSpellCheckTestHelper.jsm")
+  .onSpellCheck(textarea, () => {
+    SimpleTest.executeSoon(() => {
+      let misspelledWord = inlineSpellChecker.getMisspelledWord(editor.rootElement.firstChild, 5);
+      is(misspelledWord.startOffset, 4,
+         "Misspelled word should start from 4");
+      is(misspelledWord.endOffset, 7,
+         "Misspelled word should end at 7");
+      inlineSpellChecker.replaceWord(editor.rootElement.firstChild, 5, "aux");
+      is(textarea.value, "abc aux abc",
+         "'abx' should be replaced with 'aux'");
+      synthesizeKey("z", { accelKey: true });
+      is(textarea.value, "abc abx abc",
+         "'abx' should be restored by undo");
+      synthesizeKey("z", { accelKey: true, shiftKey: true });
+      is(textarea.value, "abc aux abc",
+         "'aux' should be restored by redo");
+
+      SimpleTest.finish();
+    });
+  });
+});
+</script>
+</body>
+</html>
--- a/extensions/spellcheck/src/mozInlineSpellChecker.cpp
+++ b/extensions/spellcheck/src/mozInlineSpellChecker.cpp
@@ -937,35 +937,22 @@ mozInlineSpellChecker::ReplaceWord(nsINo
   if (NS_WARN_IF(!mTextEditor) || NS_WARN_IF(newword.IsEmpty())) {
     return NS_ERROR_FAILURE;
   }
 
   RefPtr<nsRange> range;
   nsresult res = GetMisspelledWord(aNode, aOffset, getter_AddRefs(range));
   NS_ENSURE_SUCCESS(res, res);
 
-  if (range)
-  {
-    // This range was retrieved from the spellchecker selection. As
-    // ranges cannot be shared between selections, we must clone it
-    // before adding it to the editor's selection.
-    RefPtr<nsRange> editorRange = range->CloneRange();
-
-    AutoPlaceholderBatch phb(mTextEditor, nullptr);
-
-    RefPtr<Selection> selection = mTextEditor->GetSelection();
-    NS_ENSURE_TRUE(selection, NS_ERROR_UNEXPECTED);
-    selection->RemoveAllRanges(IgnoreErrors());
-    selection->AddRange(*editorRange, IgnoreErrors());
-
-    MOZ_ASSERT(mTextEditor);
-    DebugOnly<nsresult> rv = mTextEditor->InsertTextAsAction(newword);
-    NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "Failed to insert the new word");
+  if (!range) {
+    return NS_OK;
   }
 
+  DebugOnly<nsresult> rv = mTextEditor->ReplaceTextAsAction(newword, range);
+  NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "Failed to insert the new word");
   return NS_OK;
 }
 
 // mozInlineSpellChecker::AddWordToDictionary
 
 NS_IMETHODIMP
 mozInlineSpellChecker::AddWordToDictionary(const nsAString &word)
 {
--- a/extensions/spellcheck/tests/mochitest/test_bug1170484.html
+++ b/extensions/spellcheck/tests/mochitest/test_bug1170484.html
@@ -9,20 +9,16 @@ https://bugzilla.mozilla.org/show_bug.cg
   <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
   <script type="application/javascript">
 
   /** Test for Bug 1170484 **/
   SimpleTest.waitForExplicitFinish();
 
-  // An assertion is recorded due to nested replacing words from spellchecker but not a problem.
-  // It is necessary to detect invalid method call without event loop.
-  SimpleTest.expectAssertions(1, 1);
-
   SpecialPowers.Cu.import(
     "resource://testing-common/AsyncSpellCheckTestHelper.jsm", window);
 
   SimpleTest.waitForFocus(doTest, window);
   function doTest() {
     var mutations = 0;
     var observer = new MutationObserver(() => { mutations++; });
     observer.observe($('area'), { childList: true, characterData: true, characterDataOldValue: true, subtree: true });
--- a/extensions/spellcheck/tests/mochitest/test_bug1272623.html
+++ b/extensions/spellcheck/tests/mochitest/test_bug1272623.html
@@ -21,20 +21,16 @@ https://bugzilla.mozilla.org/show_bug.cg
 <pre id="test">
   <div id="area" contenteditable="true"><b style="font-weight:normal;">testing <span id="misspelled">spellechek</span></b></div>
   <div id="area2" contenteditable="true">testing <span id="misspelled2" style="font-weight:bold;">spellechek</span></div>
 </pre>
 <script>
 
 /** Test for Bug 1272623 **/
 
-  // 2 assertions are recorded due to nested replacing words from spellchecker but not a problem.
-  // They are necessary to detect invalid method call without event loop.
-  SimpleTest.expectAssertions(2, 2);
-
   async function performCorrection(misspelled, area) {
     synthesizeMouseAtCenter($(misspelled), {}, window);
     await new Promise(resolve => onSpellCheck($(area), resolve));
     synthesizeMouseAtCenter($(misspelled), {type: 'contextmenu'}, window);
 
     // Perform the spelling correction
     let mm = SpecialPowers.loadChromeScript(function() {
       ChromeUtils.import("resource://gre/modules/Services.jsm");