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
--- 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");