Bug 1897956 - Text Fragments: Set text fragment as :target. r=farre,dom-core

This patch makes some adjustments to follow the spec more strictly by
separating highlighting a text fragment (ie. adding the ranges to a `eTargetText` Selection)
from scrolling to it.

Scrolling to the text fragment now follows the steps given in [0] more closely,
in particular now the closest common ancestor of the target range is being used
as `:target` element.

This change also sets the focus to the start of the first text fragment, as indicated by the spec.
This resets the normal selection on the page.
The wpt test in /css/css-pseudo/target-text-005.html is based on the idea of having a same-doc
text fragment navigation with a selection.
This test fails with this patch applied, because the selection is reset.
It is currently unclear what behavior is correct here, thus the test is kept for now.

[0]: https://wicg.github.io/scroll-to-text-fragment/#invoking-text-directives

Differential Revision: https://phabricator.services.mozilla.com/D211025
This commit is contained in:
Jan-Niklas Jaeschke
2024-06-19 17:16:39 +00:00
parent d068fce0da
commit 63ac1a307c
8 changed files with 101 additions and 126 deletions

View File

@@ -10736,11 +10736,12 @@ nsresult nsDocShell::ScrollToAnchor(bool aCurHasRef, bool aNewHasRef,
// `Document::ScrollToRef()` is (presumably) the second "async" call mentioned
// in sec. 7.4.2.3.3 in the HTML spec, "Fragment navigations":
// https://html.spec.whatwg.org/#scroll-to-fragid:~:text=This%20algorithm%20will%20be%20called%20twice
const bool hasScrolledToTextFragment =
presShell->HighlightAndGoToTextFragment(scroll);
if (hasScrolledToTextFragment) {
return NS_OK;
}
const RefPtr fragmentDirective = GetDocument()->FragmentDirective();
const nsTArray<RefPtr<nsRange>> textDirectives =
fragmentDirective->FindTextFragmentsInDocument();
const bool hasTextDirectives = !textDirectives.IsEmpty();
fragmentDirective->HighlightTextDirectives(textDirectives);
// If we have no new anchor, we do not want to scroll, unless there is a
// current anchor and we are doing a history load. So return if we have no
@@ -10753,12 +10754,12 @@ nsresult nsDocShell::ScrollToAnchor(bool aCurHasRef, bool aNewHasRef,
// Both the new and current URIs refer to the same page. We can now
// browse to the hash stored in the new URI.
if (aNewHash.IsEmpty()) {
if (aNewHash.IsEmpty() && !hasTextDirectives) {
// 2. If fragment is the empty string, then return the special value top of
// the document.
//
// Tell the shell it's at an anchor without scrolling.
presShell->GoToAnchor(u""_ns, false);
presShell->GoToAnchor(u""_ns, nullptr, false);
if (scroll) {
// Scroll to the top of the page. Ignore the return value; failure to
@@ -10773,7 +10774,10 @@ nsresult nsDocShell::ScrollToAnchor(bool aCurHasRef, bool aNewHasRef,
// 3. Let potentialIndicatedElement be the result of finding a potential
// indicated element given document and fragment.
NS_ConvertUTF8toUTF16 uStr(aNewHash);
auto rv = presShell->GoToAnchor(uStr, scroll, ScrollFlags::ScrollSmoothAuto);
RefPtr<nsRange> range =
!textDirectives.IsEmpty() ? textDirectives[0] : nullptr;
auto rv =
presShell->GoToAnchor(uStr, range, scroll, ScrollFlags::ScrollSmoothAuto);
// 4. If potentialIndicatedElement is not null, then return
// potentialIndicatedElement.
@@ -10794,7 +10798,7 @@ nsresult nsDocShell::ScrollToAnchor(bool aCurHasRef, bool aNewHasRef,
if (fragmentBytes.IsEmpty()) {
// When aNewHash contains "%00", the unescaped string may be empty, and
// GoToAnchor asserts if we ask it to scroll to an empty ref.
presShell->GoToAnchor(u""_ns, false);
presShell->GoToAnchor(u""_ns, nullptr, false);
return NS_OK;
}
@@ -10811,7 +10815,8 @@ nsresult nsDocShell::ScrollToAnchor(bool aCurHasRef, bool aNewHasRef,
// there is no such anchor in the document, which is actually a success
// condition for us (we want to update the session history with the new URI no
// matter whether we actually scrolled somewhere).
presShell->GoToAnchor(decodedFragment, scroll, ScrollFlags::ScrollSmoothAuto);
presShell->GoToAnchor(decodedFragment, nullptr, scroll,
ScrollFlags::ScrollSmoothAuto);
return NS_OK;
}

View File

@@ -13189,20 +13189,25 @@ void Document::ScrollToRef() {
// as soon as https://bugzil.la/1860915 lands.
// XXX(:jjaschke): Same goes for User Activation and security aspects,
// tracked in https://bugzil.la/1888756.
const bool didScrollToTextFragment =
presShell->HighlightAndGoToTextFragment(true);
FragmentDirective()->ClearUninvokedDirectives();
const RefPtr fragmentDirective = FragmentDirective();
const nsTArray<RefPtr<nsRange>> textDirectives =
fragmentDirective->FindTextFragmentsInDocument();
fragmentDirective->HighlightTextDirectives(textDirectives);
fragmentDirective->ClearUninvokedDirectives();
// 2. If fragment is the empty string and no text directives have been
// scrolled to, then return the special value top of the document.
if (didScrollToTextFragment || mScrollToRef.IsEmpty()) {
if (textDirectives.IsEmpty() && mScrollToRef.IsEmpty()) {
return;
}
// 3. Let potentialIndicatedElement be the result of finding a potential
// indicated element given document and fragment.
NS_ConvertUTF8toUTF16 ref(mScrollToRef);
auto rv = presShell->GoToAnchor(ref, mChangeScrollPosWhenScrollingToRef);
RefPtr<nsRange> range =
!textDirectives.IsEmpty() ? textDirectives.ElementAt(0) : nullptr;
auto rv =
presShell->GoToAnchor(ref, range, mChangeScrollPosWhenScrollingToRef);
// 4. If potentialIndicatedElement is not null, then return
// potentialIndicatedElement.
@@ -13230,7 +13235,7 @@ void Document::ScrollToRef() {
// 7. Set potentialIndicatedElement to the result of finding a potential
// indicated element given document and decodedFragment.
rv = presShell->GoToAnchor(decodedFragment,
rv = presShell->GoToAnchor(decodedFragment, nullptr,
mChangeScrollPosWhenScrollingToRef);
if (NS_SUCCEEDED(rv)) {
mScrolledToRefAlready = true;

View File

@@ -12,8 +12,10 @@
#include "mozilla/dom/FragmentDirectiveBinding.h"
#include "mozilla/dom/FragmentOrElement.h"
#include "mozilla/dom/NodeBinding.h"
#include "mozilla/dom/Selection.h"
#include "mozilla/dom/Text.h"
#include "mozilla/intl/WordBreaker.h"
#include "mozilla/PresShell.h"
#include "nsComputedDOMStyle.h"
#include "nsContentUtils.h"
#include "nsDOMAttributeMap.h"
@@ -100,6 +102,34 @@ nsTArray<RefPtr<nsRange>> FragmentDirective::FindTextFragmentsInDocument() {
return textDirectiveRanges;
}
void FragmentDirective::HighlightTextDirectives(
const nsTArray<RefPtr<nsRange>>& aTextDirectiveRanges) {
MOZ_ASSERT(mDocument);
if (aTextDirectiveRanges.IsEmpty()) {
return;
}
if (!StaticPrefs::dom_text_fragments_enabled()) {
return;
}
const RefPtr<Selection> targetTextSelection =
[doc = this->mDocument]() -> Selection* {
if (auto* presShell = doc->GetPresShell()) {
return presShell->GetCurrentSelection(SelectionType::eTargetText);
}
return nullptr;
}();
if (!targetTextSelection) {
return;
}
for (const RefPtr<nsRange>& range : aTextDirectiveRanges) {
// Script won't be able to manipulate `aTextDirectiveRanges`,
// therefore we can mark `range` as known live.
targetTextSelection->AddRangeAndSelectFramesAndNotifyListeners(
MOZ_KnownLive(*range), IgnoreErrors());
}
}
/**
* @brief Determine if `aNode` should be considered when traversing the DOM.
*

View File

@@ -75,6 +75,11 @@ class FragmentDirective final : public nsISupports, public nsWrapperCache {
/** Clears all uninvoked directives. */
void ClearUninvokedDirectives() { mUninvokedTextDirectives.Clear(); }
/** Inserts all text directive ranges into a `eTargetText` `Selection`. */
MOZ_CAN_RUN_SCRIPT
void HighlightTextDirectives(
const nsTArray<RefPtr<nsRange>>& aTextDirectiveRanges);
/** Searches for the current uninvoked text directives and creates a range for
* each one that is found.
*

View File

@@ -3062,7 +3062,8 @@ UniquePtr<gfxContext> PresShell::CreateReferenceRenderingContext() {
}
// https://html.spec.whatwg.org/#scroll-to-the-fragment-identifier
nsresult PresShell::GoToAnchor(const nsAString& aAnchorName, bool aScroll,
nsresult PresShell::GoToAnchor(const nsAString& aAnchorName,
const nsRange* aFirstTextDirective, bool aScroll,
ScrollFlags aAdditionalScrollFlags) {
if (!mDocument) {
return NS_ERROR_FAILURE;
@@ -3081,12 +3082,31 @@ nsresult PresShell::GoToAnchor(const nsAString& aAnchorName, bool aScroll,
// Hold a reference to the ESM in case event dispatch tears us down.
RefPtr<EventStateManager> esm = mPresContext->EventStateManager();
// https://wicg.github.io/scroll-to-text-fragment/#invoking-text-directives
// From "Monkeypatching HTML §7.4.6.3 Scrolling to a fragment:"
// 3.4. If target is a range, then:
// 3.4.1 Set target to be the first common ancestor of target's start node and
// end node.
// 3.4.2 While target is non-null and is not an element, set target to
// target's parent.
Element* textFragmentTargetElement = [&aFirstTextDirective]() -> Element* {
nsINode* node =
aFirstTextDirective
? aFirstTextDirective->GetClosestCommonInclusiveAncestor()
: nullptr;
while (node && !node->IsElement()) {
node = node->GetParent();
}
return Element::FromNodeOrNull(node);
}();
const bool thereIsATextFragment = !!textFragmentTargetElement;
// 1. If there is no indicated part of the document, set the Document's target
// element to null.
//
// FIXME(emilio): Per spec empty fragment string should take the same
// code-path as "top"!
if (aAnchorName.IsEmpty()) {
if (aAnchorName.IsEmpty() && !thereIsATextFragment) {
NS_ASSERTION(!aScroll, "can't scroll to empty anchor name");
esm->SetContentState(nullptr, ElementState::URLTARGET);
return NS_OK;
@@ -3100,8 +3120,10 @@ nsresult PresShell::GoToAnchor(const nsAString& aAnchorName, bool aScroll,
//
// https://html.spec.whatwg.org/#target-element
// https://html.spec.whatwg.org/#find-a-potential-indicated-element
RefPtr<Element> target =
nsContentUtils::GetTargetElement(mDocument, aAnchorName);
RefPtr<Element> target = textFragmentTargetElement;
if (!target) {
target = nsContentUtils::GetTargetElement(mDocument, aAnchorName);
}
// 1. If there is no indicated part of the document, set the Document's
// target element to null.
@@ -3120,6 +3142,13 @@ nsresult PresShell::GoToAnchor(const nsAString& aAnchorName, bool aScroll,
if (target) {
if (aScroll) {
// https://wicg.github.io/scroll-to-text-fragment/#invoking-text-directives
// From "Monkeypatching HTML §7.4.6.3 Scrolling to a fragment:"
// 3.9 Let blockPosition be "center" if scrollTarget is a range, "start"
// otherwise.
const auto verticalScrollPosition =
thereIsATextFragment ? WhereToScroll(WhereToScroll::Center)
: WhereToScroll(WhereToScroll::Start);
// 3.3. TODO: Run the ancestor details revealing algorithm on target.
// 3.4. Scroll target into view, with behavior set to "auto", block set to
// "start", and inline set to "nearest".
@@ -3127,7 +3156,7 @@ nsresult PresShell::GoToAnchor(const nsAString& aAnchorName, bool aScroll,
// smooth scroll for `top` regardless below, so maybe they should!).
ScrollingInteractionContext scrollToAnchorContext(true);
MOZ_TRY(ScrollContentIntoView(
target, ScrollAxis(WhereToScroll::Start, WhenToScroll::Always),
target, ScrollAxis(verticalScrollPosition, WhenToScroll::Always),
ScrollAxis(),
ScrollFlags::AnchorScrollFlags | aAdditionalScrollFlags));
@@ -3245,46 +3274,6 @@ nsresult PresShell::ScrollToAnchor() {
ScrollAxis(), ScrollFlags::AnchorScrollFlags);
}
bool PresShell::HighlightAndGoToTextFragment(bool aScrollToTextFragment) {
MOZ_ASSERT(mDocument);
if (!StaticPrefs::dom_text_fragments_enabled()) {
return false;
}
const RefPtr<FragmentDirective> fragmentDirective =
mDocument->FragmentDirective();
nsTArray<RefPtr<nsRange>> textDirectiveRanges =
fragmentDirective->FindTextFragmentsInDocument();
if (textDirectiveRanges.IsEmpty()) {
return false;
}
const RefPtr<Selection> targetTextSelection =
GetCurrentSelection(SelectionType::eTargetText);
if (!targetTextSelection) {
return false;
}
for (RefPtr<nsRange> range : textDirectiveRanges) {
targetTextSelection->AddRangeAndSelectFramesAndNotifyListeners(
*range, IgnoreErrors());
}
if (!aScrollToTextFragment) {
return false;
}
// Scroll the last text directive into view.
nsRange* lastRange = textDirectiveRanges.LastElement();
MOZ_ASSERT(lastRange);
if (RefPtr<nsIContent> lastRangeStartContent =
nsIContent::FromNode(lastRange->GetStartContainer())) {
return ScrollContentIntoView(
lastRangeStartContent,
ScrollAxis(WhereToScroll::Center, WhenToScroll::Always),
ScrollAxis(), ScrollFlags::AnchorScrollFlags) == NS_OK;
}
return false;
}
/*
* Helper (per-continuation) for ScrollContentIntoView.
*

View File

@@ -1563,7 +1563,7 @@ class PresShell final : public nsStubDocumentObserver,
/**
* Informs the pres shell that the document is now at the anchor with
* the given name. If |aScroll| is true, scrolls the view of the
* the given name or range. If |aScroll| is true, scrolls the view of the
* document so that the anchor with the specified name is displayed at
* the top of the window. If |aAnchorName| is empty, then this informs
* the pres shell that there is no current target, and |aScroll| must
@@ -1571,7 +1571,8 @@ class PresShell final : public nsStubDocumentObserver,
* and |aScroll| is true, the scrolling may be performed with an animation.
*/
MOZ_CAN_RUN_SCRIPT
nsresult GoToAnchor(const nsAString& aAnchorName, bool aScroll,
nsresult GoToAnchor(const nsAString& aAnchorName,
const nsRange* aFirstTextDirective, bool aScroll,
ScrollFlags aAdditionalScrollFlags = ScrollFlags::None);
/**
@@ -1584,18 +1585,6 @@ class PresShell final : public nsStubDocumentObserver,
*/
MOZ_CAN_RUN_SCRIPT nsresult ScrollToAnchor();
/**
* Finds text fragments ranes in the document, highlights the ranges and
* scrolls to the last text fragment range on the page if
* `aScrollToTextFragment` is true.
*
* @param aScrollToTextFragment If true, scrolls the view to the last text
* fragment.
* @return True if scrolling happened.
*/
MOZ_CAN_RUN_SCRIPT bool HighlightAndGoToTextFragment(
bool aScrollToTextFragment);
/**
* When scroll anchoring adjusts positions in the root frame during page load,
* it may move our scroll position in the root frame.

View File

@@ -0,0 +1,2 @@
[target-text-005.html]
expected: FAIL

View File

@@ -1,23 +1,10 @@
[scroll-to-text-fragment.html]
[Test navigation with fragment: Text directive with invalid syntax (context terms without "-") should not parse as a text directive.]
expected:
if (os == "mac") and not debug: [PASS, FAIL]
[Test navigation with fragment: Exact text with no context should match text.]
expected: FAIL
[Test navigation with fragment: Exact text with prefix should match text.]
expected: FAIL
[Test navigation with fragment: Exact text with suffix should match text.]
expected: FAIL
[Test navigation with fragment: Exact text with prefix and suffix should match text.]
expected: FAIL
[Test navigation with fragment: Exact text with prefix and suffix and query equals prefix..]
expected: FAIL
[Test navigation with fragment: Text range with no context should match text.]
expected: FAIL
@@ -33,58 +20,21 @@
[Test navigation with fragment: Exact text with percent encoded spaces should match text.]
expected: FAIL
[Test navigation with fragment: Fragment directive with percent encoded syntactical characters "&,-" should match text.]
expected: FAIL
[Test navigation with fragment: Fragment directive with percent encoded non-ASCII unicode character should match text.]
expected: FAIL
[Test navigation with fragment: Fragment directive with all TextMatchChars should match text.]
expected: FAIL
[Test navigation with fragment: Multiple matching exact texts should match text.]
expected: FAIL
[Test navigation with fragment: A non-matching text directive followed by a matching text directive should match and scroll into view the second text directive.]
expected: FAIL
[Test navigation with fragment: Text directive followed by non-text directive should match text.]
expected: FAIL
[Test navigation with fragment: Multiple text directives and a non-text directive should match text.]
expected: FAIL
[Test navigation with fragment: Text directive with existing element fragment should match and scroll into view text.]
expected: FAIL
[Test navigation with fragment: Text directive with nonexistent element fragment should match and scroll into view text.]
expected: FAIL
[Test navigation with fragment: Multiple match text directive disambiguated by prefix should match the prefixed text.]
expected: FAIL
[Test navigation with fragment: Multiple match text directive disambiguated by suffix should match the suffixed text.]
expected: FAIL
[Test navigation with fragment: Multiple match text directive disambiguated by prefix and suffix should match the text with the given context.]
expected: FAIL
[Test navigation with fragment: Text directive should match when context terms are separated by node boundaries.]
expected: FAIL
[Test navigation with fragment: Text directive should match text within shadow DOM.]
expected: FAIL
[Test navigation with fragment: Text directive should horizontally scroll into view.]
expected: FAIL
[Test navigation with fragment: Uppercase TEXT directive should not parse as a text directive.]
expected:
if (os == "mac") and not debug: [PASS, FAIL]
[Test navigation with fragment: Generic fragment directive with existing element fragment should scroll to element.]
expected:
if (os == "mac") and not debug: [PASS, FAIL]
if (os == "android"): [PASS, FAIL]
if (os == "android") and debug: [PASS, FAIL]
[Test navigation with fragment: Non-matching text directive with existing element fragment should scroll to element.]