Bug 1891783 - Fix two more bugs in ShadowDOM Selection r=jjaschke,smaug,dom-core

Bug #1: AbstractRange::(Mark|Unmark)Descendants should always use
the shadow tree of web-exposed shadow root, instead of using
light DOM elements of the host.

Bug #2: aRange could possibly create mCrossShadowBoundaryRange
first (due to boundaries are in different tree), and later
moves the boundaries to the same tree. When this happens, we
should remove mCrossShadowBoundaryRange and use the default
range to represent it.

Differential Revision: https://phabricator.services.mozilla.com/D207608
This commit is contained in:
Sean Feng 2024-05-07 14:16:05 +00:00
parent 3b631b9d44
commit 25b4330bcc
7 changed files with 243 additions and 60 deletions

View file

@ -96,7 +96,7 @@ NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
// for the flattened children of aNode.
void UpdateDescendantsInFlattenedTree(const nsIContent& aNode,
bool aMarkDesendants) {
if (!aNode.IsElement() || aNode.IsHTMLElement(nsGkAtoms::slot)) {
if (!aNode.IsElement()) {
return;
}
@ -119,14 +119,14 @@ void AbstractRange::MarkDescendants(const nsINode& aNode) {
// ancestor or a descendant of one, in which case all of our descendants have
// the bit set already.
if (!aNode.IsMaybeSelected()) {
// don't set the Descendant bit on |aNode| itself
nsINode* node = aNode.GetNextNode(&aNode);
if (!node) {
if (aNode.GetShadowRootForSelection()) {
UpdateDescendantsInFlattenedTree(*aNode.AsContent(), true);
}
// If aNode has a web-exposed shadow root, use this shadow tree and ignore
// the children of aNode.
if (aNode.GetShadowRootForSelection()) {
UpdateDescendantsInFlattenedTree(*aNode.AsContent(), true);
return;
}
// don't set the Descendant bit on |aNode| itself
nsINode* node = aNode.GetNextNode(&aNode);
while (node) {
node->SetDescendantOfClosestCommonInclusiveAncestorForRangeInSelection();
if (!node->IsClosestCommonInclusiveAncestorForRangeInSelection()) {
@ -152,14 +152,14 @@ void AbstractRange::UnmarkDescendants(const nsINode& aNode) {
// common ancestor itself).
if (!aNode
.IsDescendantOfClosestCommonInclusiveAncestorForRangeInSelection()) {
// we know |aNode| doesn't have any bit set
nsINode* node = aNode.GetNextNode(&aNode);
if (!node) {
if (aNode.GetShadowRootForSelection()) {
UpdateDescendantsInFlattenedTree(*aNode.AsContent(), false);
}
// If aNode has a web-exposed shadow root, use this shadow tree and ignore
// the children of aNode.
if (aNode.GetShadowRootForSelection()) {
UpdateDescendantsInFlattenedTree(*aNode.AsContent(), false);
return;
}
// we know |aNode| doesn't have any bit set
nsINode* node = aNode.GetNextNode(&aNode);
while (node) {
node->ClearDescendantOfClosestCommonInclusiveAncestorForRangeInSelection();
if (!node->IsClosestCommonInclusiveAncestorForRangeInSelection()) {

View file

@ -106,19 +106,19 @@ template nsresult nsRange::SetStartAndEnd(
template void nsRange::DoSetRange(const RangeBoundary& aStartBoundary,
const RangeBoundary& aEndBoundary,
nsINode* aRootNode, bool aNotInsertedYet,
CollapsePolicy aCollapsePolicy);
RangeBehaviour aRangeBehaviour);
template void nsRange::DoSetRange(const RangeBoundary& aStartBoundary,
const RawRangeBoundary& aEndBoundary,
nsINode* aRootNode, bool aNotInsertedYet,
CollapsePolicy aCollapsePolicy);
RangeBehaviour aRangeBehaviour);
template void nsRange::DoSetRange(const RawRangeBoundary& aStartBoundary,
const RangeBoundary& aEndBoundary,
nsINode* aRootNode, bool aNotInsertedYet,
CollapsePolicy aCollapsePolicy);
RangeBehaviour aRangeBehaviour);
template void nsRange::DoSetRange(const RawRangeBoundary& aStartBoundary,
const RawRangeBoundary& aEndBoundary,
nsINode* aRootNode, bool aNotInsertedYet,
CollapsePolicy aCollapsePolicy);
RangeBehaviour aRangeBehaviour);
template void nsRange::CreateOrUpdateCrossShadowBoundaryRangeIfNeeded(
const RangeBoundary& aStartBoundary, const RangeBoundary& aEndBoundary);
@ -222,25 +222,26 @@ already_AddRefed<nsRange> nsRange::Create(
* aRange: The nsRange that aNewBoundary is being set to.
* aNewRoot: The shadow-including root of the container of aNewBoundary
* aNewBoundary: The new boundary
* aIsSetStart: true if ShouldCollapseBoundary is called by nsRange::SetStart,
* aIsSetStart: true if GetRangeBehaviour is called by nsRange::SetStart,
* false otherwise
* aAllowCrossShadowBoundary: Indicates whether the boundaries allowed to cross
* shadow boundary or not
*/
static CollapsePolicy ShouldCollapseBoundary(
static RangeBehaviour GetRangeBehaviour(
const nsRange* aRange, const nsINode* aNewRoot,
const RawRangeBoundary& aNewBoundary, const bool aIsSetStart,
AllowRangeCrossShadowBoundary aAllowCrossShadowBoundary) {
if (!aRange->IsPositioned()) {
return CollapsePolicy::DefaultRangeAndCrossShadowBoundaryRanges;
return RangeBehaviour::CollapseDefaultRangeAndCrossShadowBoundaryRanges;
}
MOZ_ASSERT(aRange->GetRoot());
if (aNewRoot != aRange->GetRoot()) {
// Boundaries are in different document (or not connected), so collapse
// the both the default range and the crossBoundaryRange range.
if (aNewRoot->GetComposedDoc() != aRange->GetRoot()->GetComposedDoc()) {
return CollapsePolicy::DefaultRangeAndCrossShadowBoundaryRanges;
return RangeBehaviour::CollapseDefaultRangeAndCrossShadowBoundaryRanges;
}
// Always collapse both ranges if the one of the roots is an UA widget
@ -248,14 +249,32 @@ static CollapsePolicy ShouldCollapseBoundary(
// or not.
if (AbstractRange::IsRootUAWidget(aNewRoot) ||
AbstractRange::IsRootUAWidget(aRange->GetRoot())) {
return CollapsePolicy::DefaultRangeAndCrossShadowBoundaryRanges;
return RangeBehaviour::CollapseDefaultRangeAndCrossShadowBoundaryRanges;
}
if (const CrossShadowBoundaryRange* crossShadowBoundaryRange =
aRange->GetCrossShadowBoundaryRange()) {
// Check if the existing-other-side boundary in
// aRange::mCrossShadowBoundaryRange has the same root
// as aNewRoot. If this is the case, it means default range
// is good enough to represent this range, so that we can
// merge the cross-shadow-boundary range and the default range.
const RangeBoundary& otherSideExistingBoundary =
aIsSetStart ? crossShadowBoundaryRange->EndRef()
: crossShadowBoundaryRange->StartRef();
const nsINode* otherSideRoot =
RangeUtils::ComputeRootNode(otherSideExistingBoundary.Container());
if (aNewRoot == otherSideRoot) {
return RangeBehaviour::MergeDefaultRangeAndCrossShadowBoundaryRanges;
}
}
// Different root, but same document. So we only collapse the
// default range if boundaries are allowed to cross shadow boundary.
return aAllowCrossShadowBoundary == AllowRangeCrossShadowBoundary::Yes
? CollapsePolicy::DefaultRange
: CollapsePolicy::DefaultRangeAndCrossShadowBoundaryRanges;
? RangeBehaviour::CollapseDefaultRange
: RangeBehaviour::
CollapseDefaultRangeAndCrossShadowBoundaryRanges;
}
const RangeBoundary& otherSideExistingBoundary =
@ -281,12 +300,12 @@ static CollapsePolicy ShouldCollapseBoundary(
// aNewBoundary intends to be the end.
//
// So no collapse for above cases.
return CollapsePolicy::No;
return RangeBehaviour::KeepDefaultRangeAndCrossShadowBoundaryRanges;
}
if (!aRange->MayCrossShadowBoundary() ||
aAllowCrossShadowBoundary == AllowRangeCrossShadowBoundary::No) {
return CollapsePolicy::DefaultRangeAndCrossShadowBoundaryRanges;
return RangeBehaviour::CollapseDefaultRangeAndCrossShadowBoundaryRanges;
}
const RangeBoundary& otherSideExistingCrossShadowBoundaryBoundary =
@ -308,15 +327,15 @@ static CollapsePolicy ShouldCollapseBoundary(
// Valid to the cross boundary boundary.
if (withCrossShadowBoundaryOrder && *withCrossShadowBoundaryOrder != 1) {
return CollapsePolicy::DefaultRange;
return RangeBehaviour::CollapseDefaultRange;
}
// Not valid to both existing boundaries.
return CollapsePolicy::DefaultRangeAndCrossShadowBoundaryRanges;
return RangeBehaviour::CollapseDefaultRangeAndCrossShadowBoundaryRanges;
}
MOZ_ASSERT_UNREACHABLE();
return CollapsePolicy::DefaultRangeAndCrossShadowBoundaryRanges;
return RangeBehaviour::CollapseDefaultRangeAndCrossShadowBoundaryRanges;
}
/******************************************************
* nsISupports
@ -1041,12 +1060,11 @@ void nsRange::AssertIfMismatchRootAndRangeBoundaries(
// Calling DoSetRange with either parent argument null will collapse
// the range to have both endpoints point to the other node
template <typename SPT, typename SRT, typename EPT, typename ERT>
void nsRange::DoSetRange(
const RangeBoundaryBase<SPT, SRT>& aStartBoundary,
const RangeBoundaryBase<EPT, ERT>& aEndBoundary, nsINode* aRootNode,
bool aNotInsertedYet /* = false */,
CollapsePolicy
aCollapsePolicy /* = DEFAULT_RANGE_AND_CROSS_BOUNDARY_RANGES */) {
void nsRange::
DoSetRange(const RangeBoundaryBase<SPT, SRT>& aStartBoundary,
const RangeBoundaryBase<EPT, ERT>& aEndBoundary,
nsINode* aRootNode,
bool aNotInsertedYet /* = false */, RangeBehaviour aRangeBehaviour /* = CollapseDefaultRangeAndCrossShadowBoundaryRanges */) {
mIsPositioned = aStartBoundary.IsSetAndValid() &&
aEndBoundary.IsSetAndValid() && aRootNode;
MOZ_ASSERT_IF(!mIsPositioned, !aStartBoundary.IsSet());
@ -1075,8 +1093,8 @@ void nsRange::DoSetRange(
mStart.CopyFrom(aStartBoundary, RangeBoundaryIsMutationObserved::Yes);
mEnd.CopyFrom(aEndBoundary, RangeBoundaryIsMutationObserved::Yes);
if (aCollapsePolicy ==
CollapsePolicy::DefaultRangeAndCrossShadowBoundaryRanges) {
if (aRangeBehaviour ==
RangeBehaviour::CollapseDefaultRangeAndCrossShadowBoundaryRanges) {
ResetCrossShadowBoundaryRange();
}
@ -1158,12 +1176,12 @@ void nsRange::SetStart(
return;
}
CollapsePolicy policy =
ShouldCollapseBoundary(this, newRoot, aPoint, true /* aIsSetStart= */,
aAllowCrossShadowBoundary);
RangeBehaviour behaviour =
GetRangeBehaviour(this, newRoot, aPoint, true /* aIsSetStart= */,
aAllowCrossShadowBoundary);
switch (policy) {
case CollapsePolicy::No:
switch (behaviour) {
case RangeBehaviour::KeepDefaultRangeAndCrossShadowBoundaryRanges:
// EndRef(..) may be same as mStart or not, depends on
// the value of mCrossShadowBoundaryRange->mEnd, We need to update
// mCrossShadowBoundaryRange and the default boundaries separately
@ -1176,17 +1194,22 @@ void nsRange::SetStart(
ResetCrossShadowBoundaryRange();
}
}
DoSetRange(aPoint, mEnd, mRoot, false, policy);
DoSetRange(aPoint, mEnd, mRoot, false, behaviour);
break;
case CollapsePolicy::DefaultRangeAndCrossShadowBoundaryRanges:
DoSetRange(aPoint, aPoint, newRoot, false, policy);
case RangeBehaviour::CollapseDefaultRangeAndCrossShadowBoundaryRanges:
DoSetRange(aPoint, aPoint, newRoot, false, behaviour);
break;
case CollapsePolicy::DefaultRange:
case RangeBehaviour::CollapseDefaultRange:
MOZ_ASSERT(aAllowCrossShadowBoundary ==
AllowRangeCrossShadowBoundary::Yes);
CreateOrUpdateCrossShadowBoundaryRangeIfNeeded(
aPoint, MayCrossShadowBoundaryEndRef());
DoSetRange(aPoint, aPoint, newRoot, false, policy);
DoSetRange(aPoint, aPoint, newRoot, false, behaviour);
break;
case RangeBehaviour::MergeDefaultRangeAndCrossShadowBoundaryRanges:
DoSetRange(aPoint, MayCrossShadowBoundaryEndRef(), newRoot, false,
behaviour);
ResetCrossShadowBoundaryRange();
break;
default:
MOZ_ASSERT_UNREACHABLE();
@ -1270,12 +1293,12 @@ void nsRange::SetEnd(const RawRangeBoundary& aPoint, ErrorResult& aRv,
return;
}
CollapsePolicy policy =
ShouldCollapseBoundary(this, newRoot, aPoint, false /* aIsStartStart */,
aAllowCrossShadowBoundary);
RangeBehaviour policy =
GetRangeBehaviour(this, newRoot, aPoint, false /* aIsStartStart */,
aAllowCrossShadowBoundary);
switch (policy) {
case CollapsePolicy::No:
case RangeBehaviour::KeepDefaultRangeAndCrossShadowBoundaryRanges:
// StartRef(..) may be same as mStart or not, depends on
// the value of mCrossShadowBoundaryRange->mStart, so we need to update
// mCrossShadowBoundaryRange and the default boundaries separately
@ -1290,16 +1313,21 @@ void nsRange::SetEnd(const RawRangeBoundary& aPoint, ErrorResult& aRv,
}
DoSetRange(mStart, aPoint, mRoot, false, policy);
break;
case CollapsePolicy::DefaultRangeAndCrossShadowBoundaryRanges:
case RangeBehaviour::CollapseDefaultRangeAndCrossShadowBoundaryRanges:
DoSetRange(aPoint, aPoint, newRoot, false, policy);
break;
case CollapsePolicy::DefaultRange:
case RangeBehaviour::CollapseDefaultRange:
MOZ_ASSERT(aAllowCrossShadowBoundary ==
AllowRangeCrossShadowBoundary::Yes);
CreateOrUpdateCrossShadowBoundaryRangeIfNeeded(
MayCrossShadowBoundaryStartRef(), aPoint);
DoSetRange(aPoint, aPoint, newRoot, false, policy);
break;
case RangeBehaviour::MergeDefaultRangeAndCrossShadowBoundaryRanges:
DoSetRange(MayCrossShadowBoundaryStartRef(), aPoint, newRoot, false,
policy);
ResetCrossShadowBoundaryRange();
break;
default:
MOZ_ASSERT_UNREACHABLE();
}

View file

@ -34,11 +34,19 @@ class DOMRectList;
class InspectorFontFace;
class Selection;
enum class CollapsePolicy : uint8_t {
No, // Don't need to collapse
DefaultRange, // Collapse the default range
DefaultRangeAndCrossShadowBoundaryRanges // Collapse both the default range
// and the cross boundary range
enum class RangeBehaviour : uint8_t {
// Keep both ranges
KeepDefaultRangeAndCrossShadowBoundaryRanges,
// Merge both ranges; This is the case where the range boundaries was in
// different roots initially, and becoming in the same roots now. Since
// they start to be in the same root, using normal range is good enough
// to represent it
MergeDefaultRangeAndCrossShadowBoundaryRanges,
// Collapse the default range
CollapseDefaultRange,
// Collapse both the default range and the cross-shadow-boundary range
CollapseDefaultRangeAndCrossShadowBoundaryRanges
};
} // namespace dom
} // namespace mozilla
@ -491,8 +499,8 @@ class nsRange final : public mozilla::dom::AbstractRange,
const mozilla::RangeBoundaryBase<SPT, SRT>& aStartBoundary,
const mozilla::RangeBoundaryBase<EPT, ERT>& aEndBoundary,
nsINode* aRootNode, bool aNotInsertedYet = false,
mozilla::dom::CollapsePolicy aCollapsePolicy = mozilla::dom::
CollapsePolicy::DefaultRangeAndCrossShadowBoundaryRanges);
mozilla::dom::RangeBehaviour aRangeBehaviour = mozilla::dom::
RangeBehaviour::CollapseDefaultRangeAndCrossShadowBoundaryRanges);
// Assume that this is guaranteed that this is held by the caller when
// this is used. (Note that we cannot use AutoRestore for mCalledByJS

View file

@ -302,6 +302,12 @@ skip-if = ["release_or_beta"] # requires Selection.getComposedRanges to be enabl
["test_selection_cross_shadow_boundary_multi_ranges_backward_click.html"]
skip-if = ["release_or_beta"] # requires Selection.getComposedRanges to be enabled (Nightly only)
["test_selection_cross_shadow_boundary_forward_and_backward.html"]
skip-if = ["release_or_beta"] # requires Selection.getComposedRanges to be enabled (Nightly only)
["test_selection_cross_shadow_boundary_backward_nested_click.html"]
skip-if = ["release_or_beta"] # requires Selection.getComposedRanges to be enabled (Nightly only)
["test_selection_doubleclick.html"]
["test_selection_expanding.html"]

View file

@ -0,0 +1,49 @@
<!DOCTYPE HTML>
<script src="/tests/SimpleTest/EventUtils.js"></script>
<script src="/tests/SimpleTest/SimpleTest.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<script>
SimpleTest.waitForExplicitFinish();
function run() {
const host = document.getElementById("host");
const inner = host.shadowRoot.getElementById("inner");
const innerRect = inner.getBoundingClientRect();
const innerHost = host.shadowRoot.getElementById("innerHost");
const nested = innerHost.shadowRoot.getElementById("nested");
const nestedRect = nested.getBoundingClientRect();
// Click the center of "NestedText"
synthesizeMouse(nested, nestedRect.width / 2, nestedRect.height / 2, { type: "mousedown" });
synthesizeMouse(nested, nestedRect.width / 2, nestedRect.height / 2, { type: "mouseup" });
// Click the center of "InnerText"
synthesizeMouse(inner, innerRect.width / 2, innerRect.height / 2, { type: "mousedown", shiftKey: true});
synthesizeMouse(inner, innerRect.width / 2, innerRect.height / 2, { type: "mouseup" , shiftKey: true});
// Above two clicks should select half of the content in "InnerText" and half of the content in "NestedText"
let sel = document.getSelection().getComposedRanges(host.shadowRoot, innerHost.shadowRoot)[0];
// forward selection
is(sel.startContainer, inner.firstChild, "startContainer is the InnerText");
is(sel.endContainer, nested.firstChild, "endContainer is the NestedText");
const collapsedRange = document.getSelection().getRangeAt(0);
is(collapsedRange.startContainer, inner.firstChild, "normal range's startContainer get collapsed to InnerText");
is(collapsedRange.endContainer, inner.firstChild, "normal range's endContainer get collapsed to InnerText");
SimpleTest.finish();
}
</script>
<body onload="SimpleTest.waitForFocus(run);">
<div id="host">
<template shadowrootmode="open">
<span id="inner">InnerText</span>
<div id="innerHost">
<template shadowrootmode="open">
<span id="nested">NestedText</span>
</template>
</div>
</template>
</div>
</body>

View file

@ -0,0 +1,59 @@
<!DOCTYPE HTML>
<script src="/tests/SimpleTest/EventUtils.js"></script>
<script src="/tests/SimpleTest/SimpleTest.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<script>
SimpleTest.waitForExplicitFinish();
// Test to ensure that things still work correctly when the range boundaries
// was in different roots initially and moves to the same root afterwards.
function run() {
const outer = document.getElementById("outer");
const inner = document.getElementById("host").shadowRoot.getElementById("inner");
const innerRect = inner.getBoundingClientRect();
// Click the bottom right of "InnerText"
synthesizeMouse(inner, innerRect.width, innerRect.height, { type: "mousedown" });
synthesizeMouse(inner, innerRect.width, innerRect.height, { type: "mouseup" });
// Click the top left of "OuterText"
synthesizeMouse(outer, 0, 0, { type: "mousedown", shiftKey: true});
synthesizeMouse(outer, 0, 0, { type: "mouseup" , shiftKey: true});
// Above two clicks should select both "OuterText" and "InnerText"
let sel = document.getSelection().getComposedRanges(host.shadowRoot)[0];
// forward selection
is(sel.startContainer, outer.firstChild, "startContainer is the OuterText");
is(sel.startOffset, 0, "startOffset starts at the first character");
is(sel.endContainer, inner.firstChild, "endContainer is the InnerText");
is(sel.endOffset, 9, "endOffset ends at the last character");
let normalRange = document.getSelection().getRangeAt(0);
is(normalRange.startContainer, outer.firstChild, "normal range's startContainer gets collapsed to OuterText");
is(normalRange.endContainer, outer.firstChild, "normal range's endContainer gets collapsed the OuterText");
// Click the center of "InnerText"
synthesizeMouse(inner, innerRect.width / 2, innerRect.height / 2, { type: "mousedown", shiftKey: true});
synthesizeMouse(inner, innerRect.width / 2, innerRect.height / 2, { type: "mouseup" , shiftKey: true});
sel = document.getSelection().getComposedRanges(host.shadowRoot)[0];
is(sel.startContainer, inner.firstChild, "both startContainer and endContainer are InnerText");
is(sel.endContainer, inner.firstChild, "both startContainer and endContainer are InnerText");
normalRange = document.getSelection().getRangeAt(0);
is(normalRange.startContainer, inner.firstChild, "normal range's startContainer gets collapsed to InnerText");
is(normalRange.endContainer, inner.firstChild, "normal range's endContainer gets collapsed the InnerText");
SimpleTest.finish();
}
</script>
<body onload="SimpleTest.waitForFocus(run);">
<span id="outer">OuterText</span>
<div id="host">
<template shadowrootmode="open">
<span id="inner">InnerText</span>
</template>
</div>
</body>

View file

@ -0,0 +1,33 @@
<!doctype html>
<meta charset=utf-8>
<script src=/resources/testharness.js></script>
<script src=/resources/testharnessreport.js></script>
<div id="host">
<span id="slotted">slotted</span>
</div>
<span id="outer">outer</span>
<script>
test(function(t) {
const sel = window.getSelection();
sel.setBaseAndExtent(slotted.firstChild, 3, outer.firstChild, 2);
host.attachShadow({mode: "open"}).innerHTML = "<slot></slot><span>inner</span>";
assert_equals(sel.anchorNode, slotted.firstChild);
assert_equals(sel.anchorOffset, 3);
assert_equals(sel.focusNode, outer.firstChild);
assert_equals(sel.focusOffset, 2);
const composedRange = sel.getComposedRanges(host.shadowRoot)[0];
assert_equals(composedRange.startContainer, slotted.firstChild);
assert_equals(composedRange.startOffset, 3);
assert_equals(composedRange.endContainer, outer.firstChild);
assert_equals(composedRange.endOffset, 2);
sel.empty();
assert_equals(sel.anchorNode, null);
assert_equals(sel.anchorOffset, 0);
assert_equals(sel.focusNode, null);
assert_equals(sel.focusOffset, 0);
}, "test to select a light DOM element and it becomes a slotted content after the selection");
</script>