tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 15a71a88daf16d802f2f4f0f67676e94a4d35ac1
parent ae1d88575cd979e19290b166e0c3bf0ed3bab6a6
Author: Masayuki Nakano <masayuki@d-toybox.com>
Date:   Mon, 15 Dec 2025 07:50:39 +0000

Bug 2000978 - Add new utility methods to `SelectionMovementUtils` instead of changing `GetFrameForNodeOffset()` r=smaug,webidl

Currently, `AccessibleCaretManager` uses
`SelectionMovementUtils::GetFrameForNodeOffset()` to consider the places
where to put accessible carets around a selection range.  However, the
method was not designed to work with `user-select:none` style, does not
assume there is completely invisible container like `<script>` and may
return wrong offset in some edge cases.  Additionally, it's used by
various purposes.  Therefore, "fixing" its bug causes another issue in
some other callers.

For avoiding the hell to work with regressions of the other callers and
fixing the main issue of `AccessibleCaretManager`, this patch adds new
2 utility methods which can replace the other callers with adding new
options.

Unfortunately, `AccessibleCaretManager` is not aware of ranges across
shadow DOM boundaries (bug 1607497). I tried to fix that too with the
new utility methods.  However, `ContentIterator` does not work with
shadow DOM boundaries yet (bug 2001511).  Therefore, the new methods
are not familiar with shadow DOM boundaries too.

Differential Revision: https://phabricator.services.mozilla.com/D273551

Diffstat:
Mdom/base/AbstractRange.cpp | 29+++++++++++++++++++++++++++++
Mdom/base/AbstractRange.h | 7+++++++
Mdom/webidl/AbstractRange.webidl | 10++++++++++
Mlayout/generic/SelectionMovementUtils.cpp | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlayout/generic/SelectionMovementUtils.h | 36++++++++++++++++++++++++++++++++++++
Atesting/web-platform/mozilla/tests/selection/AbstractRange_getShrunkenRangeToVisibleLeaves.html | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 520 insertions(+), 0 deletions(-)

diff --git a/dom/base/AbstractRange.cpp b/dom/base/AbstractRange.cpp @@ -8,6 +8,7 @@ #include "mozilla/Assertions.h" #include "mozilla/RangeUtils.h" +#include "mozilla/SelectionMovementUtils.h" #include "mozilla/dom/AbstractRangeBinding.h" #include "mozilla/dom/ChildIterator.h" #include "mozilla/dom/CrossShadowBoundaryRange.h" @@ -634,4 +635,32 @@ bool AbstractRange::IsRootUAWidget(const nsINode* aRoot) { } return false; } + +already_AddRefed<StaticRange> AbstractRange::GetShrunkenRangeToVisibleLeaves() + const { + if (NS_WARN_IF(!IsPositioned()) || NS_WARN_IF(Collapsed()) || + NS_WARN_IF(IsStaticRange() && !AsStaticRange()->IsValid())) { + return nullptr; + } + + const RawRangeBoundary startBoundary = + SelectionMovementUtils::GetFirstVisiblePointAtLeaf(*this); + if (MOZ_UNLIKELY(!startBoundary.IsSet())) { + return nullptr; + } + const RawRangeBoundary endBoundary = + SelectionMovementUtils::GetLastVisiblePointAtLeaf(*this); + if (MOZ_UNLIKELY(!endBoundary.IsSet())) { + return nullptr; + } + IgnoredErrorResult error; + RefPtr<StaticRange> range = + StaticRange::Create(startBoundary, endBoundary, error); + if (NS_WARN_IF(error.Failed())) { + error.SuppressException(); + return nullptr; + } + return range.forget(); +} + } // namespace mozilla::dom diff --git a/dom/base/AbstractRange.h b/dom/base/AbstractRange.h @@ -188,6 +188,13 @@ class AbstractRange : public nsISupports, */ static bool IsRootUAWidget(const nsINode* aRoot); + /** + * Return a shrunken range computed by + * SelectionMoveUtils::GetFirstVisiblePointAtLeaf() and + * SelectionMoveUtils::GetLastVisiblePointAtLeaf(). + */ + already_AddRefed<StaticRange> GetShrunkenRangeToVisibleLeaves() const; + protected: template <typename SPT, typename SRT, typename EPT, typename ERT, typename RangeType> diff --git a/dom/webidl/AbstractRange.webidl b/dom/webidl/AbstractRange.webidl @@ -19,4 +19,14 @@ interface AbstractRange { readonly attribute Node endContainer; readonly attribute unsigned long endOffset; readonly attribute boolean collapsed; + + // Chrome only method to test + // SelectionMovementUtils::GetFirstVisiblePointAtLeaf() + // and SelectionMovementUtils::GetLastVisiblePointAtLeaf(). + // + // @return {StaticRange} The shrunken range. Its start boundary is set to the + // result of GetFirstVisiblePointAtLeaf() and its end boundary is set + // to the result of GetLastVisiblePointAtLeaf(). + [ChromeOnly] + StaticRange? getShrunkenRangeToVisibleLeaves(); }; diff --git a/layout/generic/SelectionMovementUtils.cpp b/layout/generic/SelectionMovementUtils.cpp @@ -9,6 +9,7 @@ #include "ErrorList.h" #include "WordMovementType.h" #include "mozilla/CaretAssociationHint.h" +#include "mozilla/ContentIterator.h" #include "mozilla/Maybe.h" #include "mozilla/PresShell.h" #include "mozilla/dom/Document.h" @@ -410,6 +411,250 @@ nsIFrame* SelectionMovementUtils::GetFrameForNodeOffset( return returnFrame; } +// static +RawRangeBoundary SelectionMovementUtils::GetFirstVisiblePointAtLeaf( + const AbstractRange& aRange) { + MOZ_ASSERT(aRange.IsPositioned()); + MOZ_ASSERT_IF(aRange.IsStaticRange(), aRange.AsStaticRange()->IsValid()); + + // Currently, this is designed for non-collapsed range because this tries to + // return a point in aRange. Therefore, if we need to return a nearest point + // even outside aRange, we should add another utility method for making it + // accept the outer range. + MOZ_ASSERT(!aRange.Collapsed()); + + // The result should be a good point to put a UI to show something about the + // start boundary of aRange. Therefore, we should find a content which is + // visible or first unselectable one. + + // FIXME: ContentIterator does not support iterating content across shadow DOM + // boundaries. We should improve it and here support it as an option. + + // If the start boundary is in a visible and selectable `Text`, let's return + // the start boundary as-is. + if (Text* const text = Text::FromNode(aRange.GetStartContainer())) { + nsIFrame* const textFrame = text->GetPrimaryFrame(); + if (textFrame && textFrame->IsSelectable()) { + return aRange.StartRef().AsRaw(); + } + } + + // Iterate start of each node in the range so that the following loop checks + // containers first, then, inner containers and leaf nodes. + UnsafePreContentIterator iter; + if (aRange.IsDynamicRange()) { + if (NS_WARN_IF(NS_FAILED(iter.InitWithoutValidatingPoints( + aRange.StartRef().AsRaw(), aRange.EndRef().AsRaw())))) { + return {nullptr, nullptr}; + } + } else { + if (NS_WARN_IF(NS_FAILED( + iter.Init(aRange.StartRef().AsRaw(), aRange.EndRef().AsRaw())))) { + return {nullptr, nullptr}; + } + } + + // We need to ignore unselectable nodes if the range started from an + // unselectable node, for example, if starting from the document start but + // only in <dialog> which is shown as a modal one is selectable, we want to + // treat the visible selection starts from the start of the first visible + // thing in the <dialog>. + // Additionally, let's stop when we find first unselectable element in a + // selectable node. Then, the caller can show something at the end edge of + // the unselectable element rather than the leaf to make it clear that the + // selection range starts before the unselectable element. + bool foundSelectableContainer = [&]() { + nsIContent* const startContainer = + nsIContent::FromNode(aRange.GetStartContainer()); + return startContainer && startContainer->IsSelectable(); + }(); + for (iter.First(); !iter.IsDone(); iter.Next()) { + nsIContent* const content = + nsIContent::FromNodeOrNull(iter.GetCurrentNode()); + if (MOZ_UNLIKELY(!content)) { + break; + } + nsIFrame* const primaryFrame = content->GetPrimaryFrame(); + // If the content does not have any layout information, let's continue. + if (!primaryFrame) { + continue; + } + + // FYI: We don't need to skip invisible <br> at scanning start of visible + // thing like what we're doing in GetVisibleRangeEnd() because if we reached + // it, the selection range starts from end of the line so that putting UI + // around it is reasonable. + + // If the frame is unselectable, we need to stop scanning now if we're + // scanning in a selectable range. + if (!primaryFrame->IsSelectable()) { + // If we have not found a selectable content yet (this is the case when + // only a part of the document is selectable like the <dialog> case + // explained above), we should just ignore the unselectable content until + // we find first selectable element. Then, the caller can show something + // before the first child of the first selectable container in the range. + if (!foundSelectableContainer) { + continue; + } + // If we have already found a selectable content and now we reached an + // unselectable element, we should return the point of the unselectable + // element. Then, the caller can show something at the start edge of the + // unselectable element to show users that the range contains the + // unselectable element. + return {content->GetParentNode(), content->GetPreviousSibling()}; + } + // We found a visible (and maybe selectable) Text, return the start of it. + if (content->IsText()) { + return {content, 0u}; + } + // We found a replaced element such as <br>, <img>, form widget return the + // point at the content. + if (primaryFrame->IsReplaced()) { + return {content->GetParentNode(), content->GetPreviousSibling()}; + } + // <button> is a special case, whose frame is not treated as a replaced + // element, but we don't want to shrink the range into it. + if (content->IsHTMLElement(nsGkAtoms::button)) { + return {content->GetParentNode(), content->GetPreviousSibling()}; + } + // We found a leaf node like <span></span>. Return start of it. + if (!content->HasChildren()) { + return {content, 0u}; + } + foundSelectableContainer = true; + } + // If there is no visible and selectable things but the start container is + // selectable, return the original point as is. + if (foundSelectableContainer) { + return aRange.StartRef().AsRaw(); + } + // If the range is completely invisible, return unset boundary. + return {nullptr, nullptr}; +} + +// static +RawRangeBoundary SelectionMovementUtils::GetLastVisiblePointAtLeaf( + const AbstractRange& aRange) { + MOZ_ASSERT(aRange.IsPositioned()); + MOZ_ASSERT_IF(aRange.IsStaticRange(), aRange.AsStaticRange()->IsValid()); + + // Currently, this is designed for non-collapsed range because this tries to + // return a point in aRange. Therefore, if we need to return a nearest point + // even outside aRange, we should add another utility method for making it + // accept the outer range. + MOZ_ASSERT(!aRange.Collapsed()); + + // The result should be a good point to put a UI to show something about the + // end boundary of aRange. Therefore, we should find a leaf content which is + // visible or first unselectable one. + + // FIXME: ContentIterator does not support iterating content across shadow DOM + // boundaries. We should improve it and here support it as an option. + + // If the end boundary is in a visible and selectable `Text`, let's return the + // end boundary as-is. + if (Text* const text = Text::FromNode(aRange.GetEndContainer())) { + nsIFrame* const textFrame = text->GetPrimaryFrame(); + if (textFrame && textFrame->IsSelectable()) { + return aRange.EndRef().AsRaw(); + } + } + + // Iterate end of each node in the range so that the following loop checks + // containers first, then, inner containers and leaf nodes. + UnsafePostContentIterator iter; + if (aRange.IsDynamicRange()) { + if (NS_WARN_IF(NS_FAILED(iter.InitWithoutValidatingPoints( + aRange.StartRef().AsRaw(), aRange.EndRef().AsRaw())))) { + return {nullptr, nullptr}; + } + } else { + if (NS_WARN_IF(NS_FAILED( + iter.Init(aRange.StartRef().AsRaw(), aRange.EndRef().AsRaw())))) { + return {nullptr, nullptr}; + } + } + + // We need to ignore unselectable nodes if the range ends in an unselectable + // node, for example, if ending at the document end but only in <dialog> which + // is shown as a modal one, is selectable, we want to treat the visible + // selection ends at the end of the last visible thing in the <dialog>. + // Additionally, let's stop when we find first unselectable element in a + // selectable node. Then, the caller can show something at the end edge of + // the unselectable element rather than the leaf to make it clear that the + // selection range ends are the unselectable element. + bool foundSelectableContainer = [&]() { + nsIContent* const endContainer = + nsIContent::FromNode(aRange.GetEndContainer()); + return endContainer && endContainer->IsSelectable(); + }(); + for (iter.Last(); !iter.IsDone(); iter.Prev()) { + nsIContent* const content = + nsIContent::FromNodeOrNull(iter.GetCurrentNode()); + if (!content) { + break; + } + nsIFrame* const primaryFrame = content->GetPrimaryFrame(); + // If the content does not have any layout information, let's continue. + if (!primaryFrame) { + continue; + } + // If we reached an invisible <br>, we should skip it because + // AccessibleCaretManager wants to put the caret for end boundary before the + // <br> instead of at the end edge of the block. + if (nsLayoutUtils::IsInvisibleBreak(content)) { + if (primaryFrame->IsSelectable()) { + foundSelectableContainer = true; + } + continue; + } + // If the frame is unselectable, we need to stop scanning now if we're + // scanning in a selectable range. + if (!primaryFrame->IsSelectable()) { + // If we have not found a selectable content yet (this is the case when + // only a part of the document is selectable like the <dialog> case + // explained above), we should just ignore the unselectable content until + // we find first selectable element. Then, the caller can show something + // after the last child of the last selectable container in the range. + if (!foundSelectableContainer) { + continue; + } + // If we have already found a selectable content and now we reached an + // unselectable element, we should return the point after the unselectable + // element. Then, the caller can show something at the end edge of the + // unselectable element to show users that the range contains the + // unselectable element. + return {content->GetParentNode(), content}; + } + // We found a visible (and maybe selectable) Text, return the end of it. + if (Text* const text = Text::FromNode(content)) { + return {text, text->TextDataLength()}; + } + // We found a replaced element such as <br>, <img>, form widget return the + // point after the content. + if (primaryFrame->IsReplaced()) { + return {content->GetParentNode(), content}; + } + // <button> is a special case, whose frame is not treated as a replaced + // element, but we don't want to shrink the range into it. + if (content->IsHTMLElement(nsGkAtoms::button)) { + return {content->GetParentNode(), content}; + } + // We found a leaf node like <span></span>. Return end of it. + if (!content->HasChildren()) { + return {content, 0u}; + } + foundSelectableContainer = true; + } + // If there is no visible and selectable things but the end container is + // selectable, return the original point as is. + if (foundSelectableContainer) { + return aRange.EndRef().AsRaw(); + } + // If the range is completely invisible, return unset boundary. + return {nullptr, nullptr}; +} + /** * Find the first frame in an in-order traversal of the frame subtree rooted * at aFrame which is either a text frame logically at the end of a line, diff --git a/layout/generic/SelectionMovementUtils.h b/layout/generic/SelectionMovementUtils.h @@ -91,6 +91,42 @@ class SelectionMovementUtils final { uint32_t* aReturnOffset = nullptr); /** + * Return the first visible point in or at a leaf node in aRange or the first + * unselectable content if aRange starts from a selectable container. E.g., + * return the start of the first visible `Text` or the position of the first + * visible leaf element. I.e., the result may be a good point to put a UI for + * showing something around the start boundary. + * + * NOTE: This won't return any boundary point in subtrees from the tree + * containing the start container of aRange due to ContentIteratorBase's + * limitation. See bug 2001511. + * + * @param aRange Must not be collapsed because this returns a point in aRange + * so that this requires the limitation of scanning forward. + * @return A position in a `Text` or a position at an element. + */ + [[nodiscard]] static RawRangeBoundary GetFirstVisiblePointAtLeaf( + const dom::AbstractRange& aRange); + + /** + * Return the last visible point in or at a leaf node in aRange or the last + * unselectable content if aRange ends in a selectable container. E.g., return + * the end of the last visible `Text` or the position of the last visible leaf + * element. I.e., the result may be a good point to put a UI for showing + * something around the end boundary. + * + * NOTE: This won't return any boundary point in subtrees of the tree + * containing the end container of aRange due to ContentIteratorBase's + * limitation. See bug 2001511. + * + * @param aRange Must not be collapsed because this returns a point in aRange + * so that this requires the limitation of scanning forward. + * @return A position in a `Text` or a position at an element. + */ + [[nodiscard]] static RawRangeBoundary GetLastVisiblePointAtLeaf( + const dom::AbstractRange& aRange); + + /** * GetPrevNextBidiLevels will return the frames and associated Bidi levels of * the characters logically before and after a (collapsed) selection. * diff --git a/testing/web-platform/mozilla/tests/selection/AbstractRange_getShrunkenRangeToVisibleLeaves.html b/testing/web-platform/mozilla/tests/selection/AbstractRange_getShrunkenRangeToVisibleLeaves.html @@ -0,0 +1,193 @@ +<!doctype html> +<html> +<head> +<meta charset="utf-8"> +<title>Testing SelectionMovementUtils::Get(First|Last)VisiblePointAtLeaf</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/editing/include/editor-test-utils.js"></script> +<script> +"use strict"; + +addEventListener("load", () => { + const container = document.getElementById("container"); + // EditorTestUtils can initialize selection even without editable nodes so + // that we can use it. + const utils = new EditorTestUtils(container); + const greenImageSrc = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAAE0lEQVR4nGNk+M+ADJgYGCjiAwBPkgEJOTC6CgAAAABJRU5ErkJggg=="; + for (const data of [ + { + innerHTML: "{<div><span>abc</span></div>}", + expectedRange: function () { + return { + startContainer: container.querySelector("span").firstChild, + startOffset: 0, + endContainer: container.querySelector("span").firstChild, + endOffset: "abc".length, + }; + }, + }, + { + innerHTML: "{<div style=user-select:none><span>abc</span></div>}", + expectedRange: function () { + return { + startContainer: container, + startOffset: 0, + endContainer: container, + endOffset: 1, + }; + }, + }, + { + innerHTML: "{<div><span style=user-select:none>abc</span></div>}", + expectedRange: function () { + return { + startContainer: container.querySelector("div"), + startOffset: 0, + endContainer: container.querySelector("div"), + endOffset: 1, + }; + }, + }, + { + // Invisible <div>s should be ignored. + innerHTML: "{<div style=display:none>ABC</div><div><span>def</span></div><div style=display:none>GHI</div>}", + expectedRange: function () { + return { + startContainer: container.querySelector("span").firstChild, + startOffset: 0, + endContainer: container.querySelector("span").firstChild, + endOffset: "def".length, + }; + }, + }, + { + // First/last unselectable elements should be ignored. + innerHTML: "<div style=user-select:none>[ABC</div><div><span>def</span></div><div style=user-select:none>GHI]</div>", + expectedRange: function () { + return { + startContainer: container.querySelector("span").firstChild, + startOffset: 0, + endContainer: container.querySelector("span").firstChild, + endOffset: "def".length, + }; + }, + }, + { + innerHTML: "<div style=user-select:none>{<div><span style=user-select:text>abc</span></div>}</div>", + expectedRange: function () { + return { + startContainer: container.querySelector("span").firstChild, + startOffset: 0, + endContainer: container.querySelector("span").firstChild, + endOffset: "abc".length, + }; + }, + }, + { + innerHTML: "<div style=user-select:none>{<span>ABC</span><span style=user-select:text>def</span><span>GHI</span>}</div>", + expectedRange: function () { + return { + startContainer: container.querySelector("span[style]").firstChild, + startOffset: 0, + endContainer: container.querySelector("span[style]").firstChild, + endOffset: "def".length, + }; + }, + }, + { + innerHTML: "<div style=user-select:none>{<div><span style=user-select:text><span style=user-select:none>abc</span></span></div>}</div>", + expectedRange: function () { + return { + startContainer: container.querySelector("span"), + startOffset: 0, + endContainer: container.querySelector("span"), + endOffset: 1, + }; + }, + }, + { + // Don't collapse into the <img> + innerHTML: `{<span><img src=${greenImageSrc}></span>}`, + expectedRange: function () { + return { + startContainer: container.querySelector("span"), + startOffset: 0, + endContainer: container.querySelector("span"), + endOffset: 1, + }; + }, + }, + { + // Don't collapse into the <input> + innerHTML: `{<span><input></span>}`, + expectedRange: function () { + return { + startContainer: container.querySelector("span"), + startOffset: 0, + endContainer: container.querySelector("span"), + endOffset: 1, + }; + }, + }, + { + // Don't collapse into the <button> + innerHTML: `{<span><button>ABC</button></span>}`, + expectedRange: function () { + return { + startContainer: container.querySelector("span"), + startOffset: 0, + endContainer: container.querySelector("span"), + endOffset: 1, + }; + }, + }, + { + // Treat the invisible <br> as invisible. + innerHTML: "{<div><span>abc</span><br></div>}", + expectedRange: function () { + return { + startContainer: container.querySelector("span").firstChild, + startOffset: 0, + endContainer: container.querySelector("div"), + endOffset: 2, + }; + }, + }, + { + // Treat the invisible <br> as invisible. + innerHTML: "{<div><span>abc<br></span></div>}", + expectedRange: function () { + return { + startContainer: container.querySelector("span").firstChild, + startOffset: 0, + endContainer: container.querySelector("span"), + endOffset: 2, + }; + }, + }, + ]) { + test( + () => { + utils.setupEditingHost(data.innerHTML); + container.getBoundingClientRect(); + assert_equals( + EditorTestUtils.getRangeDescription( + SpecialPowers.wrap( + getSelection().getRangeAt(0) + ).getShrunkenRangeToVisibleLeaves() + ), + EditorTestUtils.getRangeDescription(data.expectedRange()) + ); + }, + `getShrunkenRangeToVisibleLeaves() when ${ + data.innerHTML.replaceAll(greenImageSrc, "...") + }` + ); + } +}, {once: true}); +</script> +</head> +<body><div id="container"></div></body> +</html>