blob: bbf710d10382c4e0c2d7de04e9a62615e9a5efc0 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "third_party/blink/renderer/core/page/scrolling/text_fragment_anchor.h"
#include "third_party/blink/renderer/core/display_lock/display_lock_utilities.h"
#include "third_party/blink/renderer/core/dom/document.h"
#include "third_party/blink/renderer/core/dom/element.h"
#include "third_party/blink/renderer/core/editing/editing_utilities.h"
#include "third_party/blink/renderer/core/editing/editor.h"
#include "third_party/blink/renderer/core/editing/ephemeral_range.h"
#include "third_party/blink/renderer/core/editing/markers/document_marker_controller.h"
#include "third_party/blink/renderer/core/editing/visible_units.h"
#include "third_party/blink/renderer/core/frame/local_dom_window.h"
#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/layout/layout_object.h"
#include "third_party/blink/renderer/core/loader/document_loader.h"
#include "third_party/blink/renderer/core/page/chrome_client.h"
#include "third_party/blink/renderer/core/page/page.h"
#include "third_party/blink/renderer/core/page/scrolling/text_fragment_selector.h"
#include "third_party/blink/renderer/core/scroll/scroll_alignment.h"
#include "third_party/blink/renderer/core/scroll/scrollable_area.h"
#include "third_party/blink/renderer/platform/search_engine_utils.h"
namespace blink {
namespace {
bool ParseTextDirective(const String& fragment_directive,
Vector<TextFragmentSelector>* out_selectors) {
DCHECK(out_selectors);
size_t start_pos = 0;
size_t end_pos = 0;
while (end_pos != kNotFound) {
if (fragment_directive.Find(kTextFragmentIdentifierPrefix, start_pos) !=
start_pos) {
// If this is not a text directive, continue to the next directive
end_pos = fragment_directive.find('&', start_pos + 1);
start_pos = end_pos + 1;
continue;
}
start_pos += kTextFragmentIdentifierPrefixStringLength;
end_pos = fragment_directive.find('&', start_pos);
String target_text;
if (end_pos == kNotFound) {
target_text = fragment_directive.Substring(start_pos);
} else {
target_text =
fragment_directive.Substring(start_pos, end_pos - start_pos);
start_pos = end_pos + 1;
}
TextFragmentSelector selector = TextFragmentSelector::Create(target_text);
if (selector.Type() != TextFragmentSelector::kInvalid)
out_selectors->push_back(selector);
}
return out_selectors->size() > 0;
}
bool CheckSecurityRestrictions(LocalFrame& frame) {
// This algorithm checks the security restrictions detailed in
// https://wicg.github.io/ScrollToTextFragment/#should-allow-a-text-fragment
// TODO(bokan): These are really only relevant for observable actions like
// scrolling. We should consider allowing highlighting regardless of these
// conditions. See the TODO in the relevant spec section:
// https://wicg.github.io/ScrollToTextFragment/#restricting-the-text-fragment
// We only allow text fragment anchors for user navigations, e.g. link
// clicks, omnibox navigations, no script navigations.
if (!frame.Loader().GetDocumentLoader()->ConsumeTextFragmentToken())
return false;
// Allow text fragments on same-origin initiated navigations.
if (frame.Loader().GetDocumentLoader()->IsSameOriginNavigation())
return true;
// Otherwise, for cross origin initiated navigations, we only allow text
// fragments if the frame is not script accessible by another frame, i.e. no
// cross origin iframes or window.open.
if (frame.Tree().Parent() || frame.GetPage()->RelatedPages().size())
return false;
return true;
}
} // namespace
// static
bool TextFragmentAnchor::GenerateNewToken(const DocumentLoader& loader) {
// Avoid invoking the text fragment for history, reload as they'll be
// clobbered by scroll restoration anyway. In particular, history navigation
// is considered browser initiated even if performed via non-activated script
// so we don't want this case to produce a token. See
// https://crbug.com/1042986 for details. This will also block form
// navigations but that's fine since the intent is to generate a token in
// real cross-page navigations only.
if (loader.GetNavigationType() != kWebNavigationTypeLinkClicked &&
loader.GetNavigationType() != kWebNavigationTypeOther) {
return false;
}
// A new permission to invoke should only be granted if the navigation had a
// transient user activation attached to it. Browser initiated navigations
// (e.g. typed address in the omnibox) don't carry the transient user
// activation bit so we have to check that separately but we consider that
// user initiated as well.
return loader.LastNavigationHadTransientUserActivation() ||
loader.IsBrowserInitiated();
}
// static
bool TextFragmentAnchor::GenerateNewTokenForSameDocument(
const String& fragment,
WebFrameLoadType load_type,
bool is_content_initiated,
SameDocumentNavigationSource source) {
if (load_type != WebFrameLoadType::kStandard ||
source != kSameDocumentNavigationDefault)
return false;
// Only allow browser-initiated navigations are allowed for same-document
// navigations (e.g. typing in the omnibox). This is restricted by the spec:
// https://wicg.github.io/scroll-to-text-fragment/#restricting-the-text-fragment.
// Note: this could change in the future but we should ensure in that case we
// look for the user gesture on the LocalFrame, rather than DocumentLoader,
// since the latter's state isn't updated by same document navigations (and
// hence why we pass individual properties to this method rather than a
// DocumentLoader reference).
if (is_content_initiated)
return false;
// Only generate a token if it's going to be consumed (i.e. the new fragment
// has a text fragment in it).
{
wtf_size_t start_pos = fragment.Find(kFragmentDirectivePrefix);
if (start_pos == kNotFound)
return false;
String fragment_directive =
fragment.Substring(start_pos + kFragmentDirectivePrefixStringLength);
Vector<TextFragmentSelector> selectors;
if (!ParseTextDirective(fragment_directive, &selectors))
return false;
}
return true;
}
TextFragmentAnchor* TextFragmentAnchor::TryCreateFragmentDirective(
const KURL& url,
LocalFrame& frame,
bool should_scroll) {
DCHECK(RuntimeEnabledFeatures::TextFragmentIdentifiersEnabled(
frame.DomWindow()));
if (!frame.GetDocument()->GetFragmentDirective())
return nullptr;
if (!CheckSecurityRestrictions(frame))
return nullptr;
Vector<TextFragmentSelector> selectors;
if (!ParseTextDirective(frame.GetDocument()->GetFragmentDirective(),
&selectors)) {
UseCounter::Count(frame.GetDocument(),
WebFeature::kInvalidFragmentDirective);
return nullptr;
}
return MakeGarbageCollected<TextFragmentAnchor>(selectors, frame,
should_scroll);
}
TextFragmentAnchor::TextFragmentAnchor(
const Vector<TextFragmentSelector>& text_fragment_selectors,
LocalFrame& frame,
bool should_scroll)
: frame_(&frame),
should_scroll_(should_scroll),
metrics_(MakeGarbageCollected<TextFragmentAnchorMetrics>(
frame_->GetDocument())) {
DCHECK(!text_fragment_selectors.IsEmpty());
DCHECK(frame_->View());
metrics_->DidCreateAnchor(
text_fragment_selectors.size(),
frame.GetDocument()->GetFragmentDirective().length());
text_fragment_finders_.ReserveCapacity(text_fragment_selectors.size());
for (TextFragmentSelector selector : text_fragment_selectors) {
text_fragment_finders_.push_back(MakeGarbageCollected<TextFragmentFinder>(
*this, selector, frame_->GetDocument(),
TextFragmentFinder::FindBufferRunnerType::kSynchronous));
}
}
bool TextFragmentAnchor::Invoke() {
// Wait until the page has been made visible before searching.
if (!frame_->GetPage()->IsPageVisible() && !page_has_been_visible_)
return true;
else
page_has_been_visible_ = true;
// We need to keep this TextFragmentAnchor alive if we're proxying an
// element fragment anchor.
if (element_fragment_anchor_) {
DCHECK(search_finished_);
return true;
}
// Only invoke once, and then a second time once the document is loaded.
// Otherwise page load performance could be significantly
// degraded, since TextFragmentFinder has O(n) performance. The reason
// for invoking twice is to give client-side rendered sites more opportunity
// to add text that can participate in text fragment invocation.
if (!frame_->GetDocument()->IsLoadCompleted()) {
// When parsing is complete the following sequence happens:
// 1. Invoke with beforematch_state_ == kNoMatchFound. This runs a match and
// causes beforematch_state_ to be set to kEventQueued, and queues
// a task to set beforematch_state_ to be set to kFiredEvent.
// 2. (maybe) Invoke with beforematch_state_ == kEventQueued.
// 3. Invoke with beforematch_state_ == kFiredEvent. This runs a match and
// causes text_searched_after_parsing_finished_ to become true.
// 4. Any future calls to Invoke before loading are ignored.
//
// TODO(chrishtr): if layout is not dirtied, we don't need to re-run
// the text finding again and again for each of the above steps.
if (has_performed_first_text_search_ && beforematch_state_ != kEventQueued)
return true;
}
// If we're done searching, return true if this hasn't been dismissed yet so
// that this is kept alive.
if (search_finished_)
return !dismissed_;
frame_->GetDocument()->Markers().RemoveMarkersOfTypes(
DocumentMarker::MarkerTypes::TextFragment());
// TODO(bokan): Once BlockHTMLParserOnStyleSheets is launched, there won't be
// a way for the user to scroll before we invoke and scroll the anchor. We
// should confirm if we can remove tracking this after that point or if we
// need a replacement metric.
if (user_scrolled_ && !did_scroll_into_view_)
metrics_->ScrollCancelled();
if (!did_find_match_) {
metrics_->DidStartSearch();
}
first_match_needs_scroll_ = should_scroll_ && !user_scrolled_;
{
// FindMatch might cause scrolling and set user_scrolled_ so reset it when
// it's done.
base::AutoReset<bool> reset_user_scrolled(&user_scrolled_, user_scrolled_);
metrics_->ResetMatchCount();
for (auto& finder : text_fragment_finders_)
finder->FindMatch();
}
if (beforematch_state_ != kEventQueued)
has_performed_first_text_search_ = true;
// Stop searching for matching text once the load event has fired. This may
// cause ScrollToTextFragment to not work on pages which dynamically load
// content: http://crbug.com/963045
if (frame_->GetDocument()->IsLoadCompleted() &&
beforematch_state_ != kEventQueued)
DidFinishSearch();
// We return true to keep this anchor alive as long as we need another invoke,
// are waiting to be dismissed, or are proxying an element fragment anchor.
return !search_finished_ || !dismissed_ || element_fragment_anchor_ ||
beforematch_state_ == kEventQueued;
}
void TextFragmentAnchor::Installed() {}
void TextFragmentAnchor::DidScroll(mojom::blink::ScrollType type) {
if (type != mojom::blink::ScrollType::kUser &&
type != mojom::blink::ScrollType::kCompositor) {
return;
}
Dismiss();
user_scrolled_ = true;
if (did_non_zero_scroll_ &&
frame_->View()->GetScrollableArea()->GetScrollOffset().IsZero()) {
metrics_->DidScrollToTop();
}
}
void TextFragmentAnchor::PerformPreRafActions() {
if (element_fragment_anchor_) {
element_fragment_anchor_->Installed();
element_fragment_anchor_->Invoke();
element_fragment_anchor_->PerformPreRafActions();
element_fragment_anchor_ = nullptr;
}
}
void TextFragmentAnchor::Trace(Visitor* visitor) const {
visitor->Trace(frame_);
visitor->Trace(element_fragment_anchor_);
visitor->Trace(metrics_);
visitor->Trace(text_fragment_finders_);
FragmentAnchor::Trace(visitor);
}
void TextFragmentAnchor::DidFindMatch(
const EphemeralRangeInFlatTree& range,
const TextFragmentAnchorMetrics::Match match_metrics,
bool is_unique) {
if (search_finished_)
return;
if (!is_unique)
metrics_->DidFindAmbiguousMatch();
// TODO(nburris): Determine what we should do with overlapping text matches.
// This implementation drops a match if it overlaps a previous match, since
// overlapping ranges are likely unintentional by the URL creator and could
// therefore indicate that the page text has changed.
if (!frame_->GetDocument()
->Markers()
.MarkersIntersectingRange(
range, DocumentMarker::MarkerTypes::TextFragment())
.IsEmpty()) {
return;
}
if (beforematch_state_ == kNoMatchFound) {
Element* enclosing_block =
EnclosingBlock(range.StartPosition(), kCannotCrossEditingBoundary);
DCHECK(enclosing_block);
frame_->GetDocument()->EnqueueAnimationFrameTask(
WTF::Bind(&TextFragmentAnchor::FireBeforeMatchEvent,
WrapPersistent(this), WrapWeakPersistent(enclosing_block)));
beforematch_state_ = kEventQueued;
return;
}
if (beforematch_state_ == kEventQueued)
return;
// TODO(jarhar): Consider what to do based on DOM/style modifications made by
// the beforematch event here and write tests for it once we decide on a
// behavior here: https://github.com/WICG/display-locking/issues/150
bool needs_style_and_layout = false;
// Apply :target to the first match
if (!did_find_match_) {
ApplyTargetToCommonAncestor(range);
needs_style_and_layout = true;
}
// Activate any find-in-page activatable display-locks in the ancestor
// chain.
if (DisplayLockUtilities::ActivateFindInPageMatchRangeIfNeeded(range)) {
// Since activating a lock dirties layout, we need to make sure it's clean
// before computing the text rect below.
needs_style_and_layout = true;
// TODO(crbug.com/1041942): It is possible and likely that activation
// signal causes script to resize something on the page. This code here
// should really yield until the next frame to give script an opportunity
// to run.
}
if (needs_style_and_layout) {
frame_->GetDocument()->UpdateStyleAndLayout(
DocumentUpdateReason::kFindInPage);
}
metrics_->DidFindMatch(match_metrics);
did_find_match_ = true;
if (first_match_needs_scroll_) {
first_match_needs_scroll_ = false;
PhysicalRect bounding_box(ComputeTextRect(range));
// Set the bounding box height to zero because we want to center the top of
// the text range.
bounding_box.SetHeight(LayoutUnit());
DCHECK(range.Nodes().begin() != range.Nodes().end());
Node& node = *range.Nodes().begin();
DCHECK(node.GetLayoutObject());
PhysicalRect scrolled_bounding_box =
node.GetLayoutObject()->ScrollRectToVisible(
bounding_box, ScrollAlignment::CreateScrollIntoViewParams(
ScrollAlignment::CenterAlways(),
ScrollAlignment::CenterAlways(),
mojom::blink::ScrollType::kProgrammatic));
did_scroll_into_view_ = true;
if (AXObjectCache* cache = frame_->GetDocument()->ExistingAXObjectCache())
cache->HandleScrolledToAnchor(&node);
metrics_->DidScroll();
// We scrolled the text into view if the main document scrolled or the text
// bounding box changed, i.e. if it was scrolled in a nested scroller.
// TODO(nburris): The rect returned by ScrollRectToVisible,
// scrolled_bounding_box, should be in frame coordinates in which case
// just checking its location would suffice, but there is a bug where it is
// actually in document coordinates and therefore does not change with a
// main document scroll.
if (!frame_->View()->GetScrollableArea()->GetScrollOffset().IsZero() ||
scrolled_bounding_box.offset != bounding_box.offset) {
did_non_zero_scroll_ = true;
metrics_->DidNonZeroScroll();
}
}
EphemeralRange dom_range =
EphemeralRange(ToPositionInDOMTree(range.StartPosition()),
ToPositionInDOMTree(range.EndPosition()));
frame_->GetDocument()->Markers().AddTextFragmentMarker(dom_range);
// Set the sequential focus navigation to the start of selection.
// Even if this element isn't focusable, "Tab" press will
// start the search to find the next focusable element from this element.
frame_->GetDocument()->SetSequentialFocusNavigationStartingPoint(
range.StartPosition().NodeAsRangeFirstNode());
}
void TextFragmentAnchor::DidFinishSearch() {
DCHECK(!search_finished_);
search_finished_ = true;
metrics_->SetSearchEngineSource(HasSearchEngineSource());
metrics_->ReportMetrics();
if (!did_find_match_) {
dismissed_ = true;
DCHECK(!element_fragment_anchor_);
element_fragment_anchor_ = ElementFragmentAnchor::TryCreate(
frame_->GetDocument()->Url(), *frame_, should_scroll_);
if (element_fragment_anchor_) {
// Schedule a frame so we can invoke the element anchor in
// PerformPreRafActions.
frame_->GetPage()->GetChromeClient().ScheduleAnimation(frame_->View());
}
}
}
bool TextFragmentAnchor::Dismiss() {
// To decrease the likelihood of the user dismissing the highlight before
// seeing it, we only dismiss the anchor after search_finished_, at which
// point we've scrolled it into view or the user has started scrolling the
// page.
if (!search_finished_)
return false;
if (!did_find_match_ || dismissed_)
return true;
DCHECK(!should_scroll_ || did_scroll_into_view_ || user_scrolled_);
frame_->GetDocument()->Markers().RemoveMarkersOfTypes(
DocumentMarker::MarkerTypes::TextFragment());
dismissed_ = true;
metrics_->Dismissed();
return dismissed_;
}
void TextFragmentAnchor::ApplyTargetToCommonAncestor(
const EphemeralRangeInFlatTree& range) {
Node* common_node = range.CommonAncestorContainer();
while (common_node && common_node->getNodeType() != Node::kElementNode) {
common_node = common_node->parentNode();
}
DCHECK(common_node);
if (common_node) {
auto* target = DynamicTo<Element>(common_node);
frame_->GetDocument()->SetCSSTarget(target);
}
}
void TextFragmentAnchor::FireBeforeMatchEvent(Element* element) {
if (RuntimeEnabledFeatures::BeforeMatchEventEnabled(
frame_->GetDocument()->GetExecutionContext())) {
element->DispatchEvent(
*Event::CreateBubble(event_type_names::kBeforematch));
}
beforematch_state_ = kFiredEvent;
}
void TextFragmentAnchor::SetTickClockForTesting(
const base::TickClock* tick_clock) {
metrics_->SetTickClockForTesting(tick_clock);
}
bool TextFragmentAnchor::HasSearchEngineSource() {
AtomicString referrer = frame_->GetDocument()->referrer();
// TODO(crbug.com/1133823): Add test case for valid referrer.
if (!referrer)
return false;
return IsKnownSearchEngine(referrer);
}
} // namespace blink