Bug 85686 - Mimic Chromium's getSelection().toString() behaviour r=masayuki,dom-core,webidl,smaug

Basically when getSelection().toString() is called, Chromium may
return an serialization on a different content than Firefox.
This patch tries to address this webcompat issue by mimicing
the same behaviour.

We should still address the spec issues, but this seems to be
an acceptable compromise.

Differential Revision: https://phabricator.services.mozilla.com/D239657
This commit is contained in:
Sean Feng
2025-03-17 13:36:16 +00:00
parent 81c8815d7d
commit e46b69d89c
26 changed files with 370 additions and 44 deletions

View File

@@ -14,6 +14,7 @@
#include "mozilla/StaticPtr.h"
#include "mozilla/dom/FragmentOrElement.h"
#include "mozilla/dom/AncestorIterator.h"
#include "DOMIntersectionObserver.h"
#include "mozilla/AsyncEventDispatcher.h"
#include "mozilla/EffectSet.h"
@@ -1034,6 +1035,28 @@ Element* nsIContent::GetAutofocusDelegate(IsFocusableFlags aFlags) const {
return nullptr;
}
bool nsIContent::CanStartSelectionAsWebCompatHack() const {
if (!StaticPrefs::dom_selection_mimic_chrome_tostring_enabled()) {
return true;
}
for (const nsIContent* content = this; content;
content = content->GetFlattenedTreeParent()) {
if (content->IsEditable()) {
return true;
}
nsIFrame* frame = content->GetPrimaryFrame();
if (!frame) {
return true;
}
if (!frame->IsSelectable(nullptr)) {
return false;
}
}
return true;
}
Element* nsIContent::GetFocusDelegate(IsFocusableFlags aFlags) const {
const nsIContent* whereToLook = this;
if (ShadowRoot* root = GetShadowRoot()) {

View File

@@ -509,7 +509,8 @@ void printRange(nsRange* aDomRange) {
}
#endif /* PRINT_RANGE */
void Selection::Stringify(nsAString& aResult, FlushFrames aFlushFrames) {
void Selection::Stringify(nsAString& aResult, CallerType aCallerType,
FlushFrames aFlushFrames) {
if (aFlushFrames == FlushFrames::Yes) {
// We need FlushType::Frames here to make sure frames have been created for
// the selected content. Use mFrameSelection->GetPresShell() which returns
@@ -524,8 +525,17 @@ void Selection::Stringify(nsAString& aResult, FlushFrames aFlushFrames) {
}
IgnoredErrorResult rv;
ToStringWithFormat(u"text/plain"_ns, nsIDocumentEncoder::SkipInvisibleContent,
0, aResult, rv);
uint32_t flags = nsIDocumentEncoder::SkipInvisibleContent;
if (StaticPrefs::dom_selection_mimic_chrome_tostring_enabled() &&
Type() == SelectionType::eNormal &&
aCallerType == CallerType::NonSystem) {
if (mFrameSelection && !mFrameSelection->GetLimiter()) {
// NonSystem and non-independent selection
flags |= nsIDocumentEncoder::MimicChromeToStringBehaviour;
}
}
ToStringWithFormat(u"text/plain"_ns, flags, 0, aResult, rv);
if (rv.Failed()) {
aResult.Truncate();
}
@@ -559,7 +569,17 @@ void Selection::ToStringWithFormat(const nsAString& aFormatType,
return;
}
encoder->SetSelection(this);
Selection* selectionToEncode = this;
if (aFlags & nsIDocumentEncoder::MimicChromeToStringBehaviour) {
if (const nsFrameSelection* sel =
presShell->GetLastSelectionForToString()) {
MOZ_ASSERT(StaticPrefs::dom_selection_mimic_chrome_tostring_enabled());
selectionToEncode = &sel->NormalSelection();
}
}
encoder->SetSelection(selectionToEncode);
if (aWrapCol != 0) encoder->SetWrapColumn(aWrapCol);
rv = encoder->EncodeToString(aReturn);
@@ -2446,6 +2466,12 @@ void Selection::AddRangeJS(nsRange& aRange, ErrorResult& aRv) {
mCalledByJS = true;
RefPtr<Document> document(GetDocument());
AddRangeAndSelectFramesAndNotifyListenersInternal(aRange, document, aRv);
if (StaticPrefs::dom_selection_mimic_chrome_tostring_enabled() &&
!aRv.Failed()) {
if (auto* presShell = GetPresShell()) {
presShell->UpdateLastSelectionForToString(mFrameSelection);
}
}
}
void Selection::AddRangeAndSelectFramesAndNotifyListeners(nsRange& aRange,
@@ -3353,6 +3379,12 @@ void Selection::SelectAllChildrenJS(nsINode& aNode, ErrorResult& aRv) {
AutoRestore<bool> calledFromJSRestorer(mCalledByJS);
mCalledByJS = true;
SelectAllChildren(aNode, aRv);
if (StaticPrefs::dom_selection_mimic_chrome_tostring_enabled() &&
!aRv.Failed()) {
if (auto* presShell = GetPresShell()) {
presShell->UpdateLastSelectionForToString(mFrameSelection);
}
}
}
void Selection::SelectAllChildren(nsINode& aNode, ErrorResult& aRv) {
@@ -4122,6 +4154,12 @@ void Selection::SetBaseAndExtentJS(nsINode& aAnchorNode, uint32_t aAnchorOffset,
AutoRestore<bool> calledFromJSRestorer(mCalledByJS);
mCalledByJS = true;
SetBaseAndExtent(aAnchorNode, aAnchorOffset, aFocusNode, aFocusOffset, aRv);
if (StaticPrefs::dom_selection_mimic_chrome_tostring_enabled() &&
!aRv.Failed()) {
if (auto* presShell = GetPresShell()) {
presShell->UpdateLastSelectionForToString(mFrameSelection);
}
}
}
void Selection::SetBaseAndExtent(nsINode& aAnchorNode, uint32_t aAnchorOffset,

View File

@@ -514,7 +514,9 @@ class Selection final : public nsSupportsWeakReference,
*/
enum class FlushFrames { No, Yes };
MOZ_CAN_RUN_SCRIPT
void Stringify(nsAString& aResult, FlushFrames = FlushFrames::Yes);
void Stringify(nsAString& aResult,
CallerType aCallerType = CallerType::System,
FlushFrames = FlushFrames::Yes);
/**
* Indicates whether the node is part of the selection. If partlyContained

View File

@@ -357,6 +357,22 @@ class nsIContent : public nsINode {
*/
inline nsIContent* GetFlattenedTreeParent() const;
// This method is used to provide a similar CanStartSelection behaviour in
// Chromium, see the link for exact Chromium's behaviour.
// https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/dom/node.cc;l=1909;drc=58fb75d86a0ad2642beec2d6c16b1e6c008e33cd;bpv=1;bpt=1
//
// Basically, Chromium has this method to decide if the selection should be
// changed or remain at the current element when an element is focused. This
// creates a webcompat issue for when window.getSelection().toString()
// is called, web authors expect Firefox to serialize the old content, but
// Firefox decides to serialize a different content.
//
// This method, along with PresShell::mLastSelectionForToString is used to
// address this webcompat issue.
//
// THIS METHOD SHOULD BE USED WITH EXTRA CAUTIOUS.
bool CanStartSelectionAsWebCompatHack() const;
protected:
// Handles getting inserted or removed directly under a <slot> element.
// This is meant to only be called from the two functions below.

View File

@@ -764,7 +764,13 @@ TextInputSelectionController::ScrollCharacter(bool aRight) {
void TextInputSelectionController::SelectionWillTakeFocus() {
if (mFrameSelection) {
if (PresShell* shell = mFrameSelection->GetPresShell()) {
shell->FrameSelectionWillTakeFocus(*mFrameSelection);
// text input selection always considers to move the
// selection.
shell->FrameSelectionWillTakeFocus(
*mFrameSelection,
StaticPrefs::dom_selection_mimic_chrome_tostring_enabled()
? PresShell::CanMoveLastSelectionForToString::Yes
: PresShell::CanMoveLastSelectionForToString::No);
}
}
}

View File

@@ -236,6 +236,12 @@ interface nsIDocumentEncoder : nsISupports
const unsigned long RequiresReinitAfterOutput = (1 << 28);
const unsigned long AllowCrossShadowBoundary = (1 << 29);
/**
* Whether window.getSelection().toString() should mimic Chrome's
* behaviour. See nsIContent::CanStartSelection for more details.
*/
const unsigned long MimicChromeToStringBehaviour = (1 << 30);
/**
* Initialize with a pointer to the document and the mime type.
* Resets wrap column to 72 and resets node fixup.

View File

@@ -84,6 +84,7 @@ interface Selection {
[Throws]
boolean containsNode(Node node,
optional boolean allowPartialContainment = false);
[NeedsCallerType]
stringifier DOMString ();
};

View File

@@ -828,7 +828,8 @@ nsAutoString AccessibleCaretManager::StringifiedSelection() const {
nsAutoString str;
RefPtr<Selection> selection = GetSelection();
if (selection) {
selection->Stringify(str, mLayoutFlusher.mAllowFlushing
selection->Stringify(str, CallerType::System,
mLayoutFlusher.mAllowFlushing
? Selection::FlushFrames::Yes
: Selection::FlushFrames::No);
}

View File

@@ -773,6 +773,7 @@ bool PresShell::AccessibleCaretEnabled(nsIDocShell* aDocShell) {
PresShell::PresShell(Document* aDocument)
: mDocument(aDocument),
mViewManager(nullptr),
mLastSelectionForToString(nullptr),
mAutoWeakFrames(nullptr),
#ifdef ACCESSIBILITY
mDocAccessible(nullptr),
@@ -1539,7 +1540,8 @@ bool PresShell::FixUpFocus() {
void PresShell::SelectionWillTakeFocus() {
if (mSelection) {
FrameSelectionWillTakeFocus(*mSelection);
FrameSelectionWillTakeFocus(*mSelection,
CanMoveLastSelectionForToString::No);
}
}
@@ -1584,11 +1586,20 @@ void PresShell::FrameSelectionWillLoseFocus(nsFrameSelection& aFrameSelection) {
}
if (mSelection) {
FrameSelectionWillTakeFocus(*mSelection);
FrameSelectionWillTakeFocus(*mSelection,
CanMoveLastSelectionForToString::No);
}
}
void PresShell::FrameSelectionWillTakeFocus(nsFrameSelection& aFrameSelection) {
void PresShell::FrameSelectionWillTakeFocus(
nsFrameSelection& aFrameSelection,
CanMoveLastSelectionForToString aCanMoveLastSelectionForToString) {
if (StaticPrefs::dom_selection_mimic_chrome_tostring_enabled()) {
if (aCanMoveLastSelectionForToString ==
CanMoveLastSelectionForToString::Yes) {
UpdateLastSelectionForToString(&aFrameSelection);
}
}
if (mFocusedFrameSelection == &aFrameSelection) {
#ifdef XP_MACOSX
// FIXME: Mac needs to update the global selection cache, even if the
@@ -1616,6 +1627,14 @@ void PresShell::FrameSelectionWillTakeFocus(nsFrameSelection& aFrameSelection) {
}
}
void PresShell::UpdateLastSelectionForToString(
const nsFrameSelection* aFrameSelection) {
MOZ_ASSERT(StaticPrefs::dom_selection_mimic_chrome_tostring_enabled());
if (mLastSelectionForToString != aFrameSelection) {
mLastSelectionForToString = aFrameSelection;
}
}
NS_IMETHODIMP
PresShell::SetDisplaySelection(int16_t aToggle) {
mSelection->SetDisplaySelection(aToggle);

View File

@@ -574,12 +574,17 @@ class PresShell final : public nsStubDocumentObserver,
void ClearFrameRefs(nsIFrame* aFrame);
enum class CanMoveLastSelectionForToString { No, Yes };
// Clears the selection of the older focused frame selection if any.
void FrameSelectionWillTakeFocus(nsFrameSelection&);
void FrameSelectionWillTakeFocus(nsFrameSelection&,
CanMoveLastSelectionForToString);
// Clears and repaint mFocusedFrameSelection if it matches the argument.
void FrameSelectionWillLoseFocus(nsFrameSelection&);
// Update mLastSelectionForToString to the given frame selection.
void UpdateLastSelectionForToString(const nsFrameSelection*);
/**
* Get a reference rendering context. This is a context that should not
* be rendered to, but is suitable for measuring text and performing
@@ -652,6 +657,10 @@ class PresShell final : public nsStubDocumentObserver,
*/
nsFrameSelection* GetLastFocusedFrameSelection();
const nsFrameSelection* GetLastSelectionForToString() const {
return mLastSelectionForToString;
}
/**
* Interface to dispatch events via the presshell
* @note The caller must have a strong reference to the PresShell.
@@ -2999,6 +3008,11 @@ class PresShell final : public nsStubDocumentObserver,
// hide if we focus another selection. May or may not be the same as
// `mSelection`.
RefPtr<nsFrameSelection> mFocusedFrameSelection;
// This the frame selection that will be used when getSelection().toString()
// is called. See nsIContent::CanStartSelection for its reasoning.
RefPtr<const nsFrameSelection> mLastSelectionForToString;
RefPtr<nsCaret> mCaret;
RefPtr<nsCaret> mOriginalCaret;
RefPtr<AccessibleCaretEventHub> mAccessibleCaretEventHub;

View File

@@ -1408,7 +1408,10 @@ nsresult nsFrameSelection::TakeFocus(nsIContent& aNewFocus,
__FUNCTION__, &aNewFocus, aContentOffset, aContentEndOffset,
static_cast<int>(aHint), static_cast<int>(aFocusMode)));
mPresShell->FrameSelectionWillTakeFocus(*this);
mPresShell->FrameSelectionWillTakeFocus(
*this, aNewFocus.CanStartSelectionAsWebCompatHack()
? PresShell::CanMoveLastSelectionForToString::Yes
: PresShell::CanMoveLastSelectionForToString::No);
// Clear all table selection data
mTableSelection.mMode = TableSelectionMode::None;
@@ -3115,8 +3118,17 @@ void nsFrameSelection::DisconnectFromPresShell() {
MOZ_ASSERT(mDomSelections[i]);
mDomSelections[i]->Clear(nullptr);
}
if (auto* presshell = mPresShell) {
if (const nsFrameSelection* sel =
presshell->GetLastSelectionForToString()) {
if (sel == this) {
presshell->UpdateLastSelectionForToString(nullptr);
}
}
mPresShell = nullptr;
}
}
#ifdef XP_MACOSX
/**

View File

@@ -166,7 +166,11 @@ function test()
// iframe/input/custom-element contents must not be included on the parent
// selection.
checkCharacter(sel, "f", false, "iframe (checking on parent)");
checkCharacter(sel, "i", false, "input (checking on parent)");
checkCharacter(sel, "i",
SpecialPowers.getBoolPref("dom.selection.mimic_chrome_tostring.enabled")
? aInputShouldBeSelected
: false,
"input (checking on parent)");
checkCharacter(sel, "x", false, "Custom element contents (checking on parent)");
var selInIFrame = iframe.contentWindow.getSelection().toString();

View File

@@ -87,7 +87,11 @@ function test()
// input contents must not be included on the parent
// selection.
checkCharacter(sel, "i", false, "input (checking on parent)");
checkCharacter(sel, "i",
SpecialPowers.getBoolPref("dom.selection.mimic_chrome_tostring.enabled")
? aInputShouldBeSelected
: false
, "input (checking on parent)");
var selInput = getSelectionForEditor(input).toString();
checkCharacter(selInput, "i", aInputShouldBeSelected, "input");

View File

@@ -5231,6 +5231,12 @@
value: @IS_NIGHTLY_BUILD@
mirror: always
# Mimic Chrome's window.getSelection().toString() behaviour
- name: dom.selection.mimic_chrome_tostring.enabled
type: bool
value: @IS_NIGHTLY_BUILD@
mirror: always
# When this pref is enabled:
# - Shadow DOM is not pierced by default anymore
# - The method accepts optional CaretPositionFromPointOptions to allow piercing

View File

@@ -1,5 +1,2 @@
[email-set-value.html]
expected:
if (os == "android") and fission: [OK, TIMEOUT]
[setValue(sanitizedValue) is reflected in visible text field content]
expected: FAIL
prefs: [dom.selection.mimic_chrome_tostring.enabled:true]

View File

@@ -1,3 +1,2 @@
[textarea-insertfrompaste-type-inputevent-data-withnewline-atend.html]
[Input event data for inputType insertFromPaste should be set]
expected: FAIL
prefs: [dom.selection.mimic_chrome_tostring.enabled:true]

View File

@@ -1,3 +1,2 @@
[textarea-insertfrompaste-type-inputevent-data-withnewline-atstart.html]
[Input event data for inputType insertFromPaste should be set]
expected: FAIL
prefs: [dom.selection.mimic_chrome_tostring.enabled:true]

View File

@@ -1,3 +1,2 @@
[textarea-insertfrompaste-type-inputevent-data.html]
[Input event data for inputType insertFromPaste should be set]
expected: FAIL
prefs: [dom.selection.mimic_chrome_tostring.enabled:true]

View File

@@ -1,5 +1,2 @@
[select.htm]
expected:
if (os == "android") and fission: [OK, TIMEOUT]
[HTML5 Selection: Call select() on a text field]
expected: FAIL
prefs: [dom.selection.mimic_chrome_tostring.enabled:true]

View File

@@ -1,5 +1,2 @@
[selectionStartEnd.htm]
expected:
if (os == "android") and fission: [OK, TIMEOUT]
[HTML5 Selection: Set selectionStart and selectionEnd on a text field]
expected: FAIL
prefs: [dom.selection.mimic_chrome_tostring.enabled:true]

View File

@@ -1,5 +1,2 @@
[setSelectionRange.htm]
expected:
if (os == "android") and fission: [OK, TIMEOUT]
[HTML5 Selection: Call setSelectionRange() on a text field]
expected: FAIL
prefs: [dom.selection.mimic_chrome_tostring.enabled:true]

View File

@@ -0,0 +1,2 @@
[stringifier_editable_element.tentative.html]
prefs:[dom.selection.mimic_chrome_tostring.enabled:true]

View File

@@ -26,8 +26,8 @@
await utils.sendCopyShortcutKey();
await utils.sendPasteShortcutKey();
// Event data should now be set with the first line of the text.
assert_equals(selectedData, "Copying and pasting first line including interchange newline\n");
assert_equals(eventData, selectedData);
assert_equals(selectedData.replace(/\r\n/g, "\n"), "Copying and pasting first line including interchange newline\n");
assert_equals(eventData, selectedData.replace(/\r\n/g, "\n"));
}, "Input event data for inputType insertFromPaste should be set");
</script>
</body>

View File

@@ -27,8 +27,8 @@ insertFromPaste</textarea>
await utils.sendCopyShortcutKey();
await utils.sendPasteShortcutKey();
// Event data should now be set with the second line of the text
assert_equals(selectedData, "\nat the start should set the event.data with the selected part for inputType");
assert_equals(eventData, selectedData);
assert_equals(selectedData.replace(/\r\n/g, "\n"), "\nat the start should set the event.data with the selected part for inputType");
assert_equals(eventData, selectedData.replace(/\r\n/g, "\n"));
}, "Input event data for inputType insertFromPaste should be set");
</script>
</body>

View File

@@ -0,0 +1,187 @@
<!DOCTYPE HTML>
<meta charset=utf-8>
<title>Selection: stringifier for editable elements</title>
<!--
There are two open issues about how this should behave
https://github.com/w3c/selection-api/issues/83
https://github.com/w3c/selection-api/issues/7
-->
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<script src="/resources/testdriver-actions.js"></script>
<body>
<input id="dummyInput"></input>
<input id="textInput" value="This is a text">
<textarea id="textArea" rows="5" cols="40">
Line one
Line two
</textarea>
<button id="button">Button</button>
<a id="anchor">Anchor</a>
<span id="text">Text</span>
</body>
<script>
function reset() {
window.getSelection().empty();
dummyInput.focus();
}
window.onload = () => {
test(() => {
reset();
textInput.select();
assert_equals(document.activeElement, textInput);
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "This is a text");
}, "select the entire input should result all the content");
test(() => {
reset();
textInput.select();
dummyInput.focus();
assert_equals(document.activeElement, dummyInput);
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "");
}, "toString() should return empty when the focus is not on the editable content");
test(() => {
reset();
textInput.selectionStart = 3;
textInput.selectionEnd = 7;
assert_equals(document.activeElement, dummyInput);
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "");
textInput.focus();
assert_equals(document.activeElement, textInput);
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "s is");
}, "toString() works with selectionStart and selectionEnd for input");
test(() => {
reset();
textArea.select();
assert_equals(document.activeElement, textArea);
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "\n Line one\n Line two\n\n ");
}, "select the entire textarea should result all the content");
test(() => {
reset();
textArea.selectionStart = 3;
textArea.selectionEnd = 12;
assert_equals(document.activeElement, dummyInput);
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "");
textArea.focus();
assert_equals(document.activeElement, textArea);
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "Line one\n");
}, "toString() works with selectionStart and selectionEnd for textarea");
test(() => {
reset();
textInput.select();
button.focus();
assert_equals(document.activeElement, button);
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "This is a text");
}, "toString() works even if a click just occured on a button");
promise_test((t) => {
reset();
textInput.select();
return new Promise(r => {
anchor.addEventListener("click", function() {
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "This is a text");
r();
}, {once: true});
anchor.click();
});
}, "toString() works for programatically calling .click() on anchor (without href)");
promise_test((t) => {
reset();
textInput.select();
return new Promise(r => {
anchor.addEventListener("click", function() {
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "");
r();
}, {once : true});
test_driver.click(anchor);
});
}, "toString() doesn't work for actual clicking the anchor (without href)");
promise_test((t) => {
reset();
textInput.select();
anchor.href = "#";
return new Promise(r => {
anchor.addEventListener("click", function() {
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "This is a text");
r();
}, {once: true});
anchor.click(); // anchor has href now
});
}, "toString() works for programatically calling .click() on anchor (with href)");
promise_test((t) => {
reset();
textInput.select();
return new Promise(r => {
anchor.addEventListener("click", function() {
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "This is a text");
r();
}, {once : true});
test_driver.click(anchor); // anchor has href now
});
}, "toString() also works for actual clicking the anchor (with href)");
promise_test((t) => {
reset();
textInput.select();
return new Promise(async r => {
anchor.addEventListener("click", function() {
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "");
r();
}, {once : true});
await test_driver.click(text);
test_driver.click(anchor);
});
}, "Click on a text prior to toString() moves the seleciton");
promise_test((t) => {
reset();
textInput.select();
text.style = "user-select: none";
return new Promise(async r => {
anchor.addEventListener("click", function() {
assert_equals(window.getSelection().toString().replace(/\r\n/g, "\n"), "This is a text");
r();
}, {once : true});
await test_driver.click(text);
test_driver.click(anchor);
});
}, "Click on a `user-select:none` text prior to toString() doesn't move the seleciton");
};
</script>
</html>