1 // Copyright 2019 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 #include "third_party/blink/renderer/core/page/scrolling/text_fragment_anchor.h"
6 
7 #include "third_party/blink/renderer/core/display_lock/display_lock_utilities.h"
8 #include "third_party/blink/renderer/core/dom/document.h"
9 #include "third_party/blink/renderer/core/dom/element.h"
10 #include "third_party/blink/renderer/core/editing/editor.h"
11 #include "third_party/blink/renderer/core/editing/ephemeral_range.h"
12 #include "third_party/blink/renderer/core/editing/markers/document_marker_controller.h"
13 #include "third_party/blink/renderer/core/editing/visible_units.h"
14 #include "third_party/blink/renderer/core/frame/local_dom_window.h"
15 #include "third_party/blink/renderer/core/frame/local_frame.h"
16 #include "third_party/blink/renderer/core/layout/layout_object.h"
17 #include "third_party/blink/renderer/core/loader/document_loader.h"
18 #include "third_party/blink/renderer/core/page/chrome_client.h"
19 #include "third_party/blink/renderer/core/page/page.h"
20 #include "third_party/blink/renderer/core/page/scrolling/text_fragment_selector.h"
21 #include "third_party/blink/renderer/core/scroll/scroll_alignment.h"
22 #include "third_party/blink/renderer/core/scroll/scrollable_area.h"
23 
24 namespace blink {
25 
26 namespace {
27 
ParseTextDirective(const String & fragment,Vector<TextFragmentSelector> * out_selectors)28 bool ParseTextDirective(const String& fragment,
29                         Vector<TextFragmentSelector>* out_selectors) {
30   DCHECK(out_selectors);
31 
32   size_t start_pos = 0;
33   size_t end_pos = 0;
34   while (end_pos != kNotFound) {
35     if (fragment.Find(kTextFragmentIdentifierPrefix, start_pos) != start_pos) {
36       // If this is not a text directive, continue to the next directive
37       end_pos = fragment.find('&', start_pos + 1);
38       start_pos = end_pos + 1;
39       continue;
40     }
41 
42     start_pos += kTextFragmentIdentifierPrefixStringLength;
43     end_pos = fragment.find('&', start_pos);
44 
45     String target_text;
46     if (end_pos == kNotFound) {
47       target_text = fragment.Substring(start_pos);
48     } else {
49       target_text = fragment.Substring(start_pos, end_pos - start_pos);
50       start_pos = end_pos + 1;
51     }
52 
53     TextFragmentSelector selector = TextFragmentSelector::Create(target_text);
54     if (selector.Type() != TextFragmentSelector::kInvalid)
55       out_selectors->push_back(selector);
56   }
57 
58   return out_selectors->size() > 0;
59 }
60 
CheckSecurityRestrictions(LocalFrame & frame,bool same_document_navigation)61 bool CheckSecurityRestrictions(LocalFrame& frame,
62                                bool same_document_navigation) {
63   // This algorithm checks the security restrictions detailed in
64   // https://wicg.github.io/ScrollToTextFragment/#should-allow-a-text-fragment
65   // TODO(bokan): These are really only relevant for observable actions like
66   // scrolling. We should consider allowing highlighting regardless of these
67   // conditions. See the TODO in the relevant spec section:
68   // https://wicg.github.io/ScrollToTextFragment/#restricting-the-text-fragment
69 
70   // History navigation is special because it's considered to be browser
71   // initiated even if the navigation originated via use of the history API
72   // within the renderer. We avoid creating a text fragment for history
73   // navigations since history scroll restoration should take precedence but
74   // it'd be bad if we ever got here for a history navigation since the check
75   // below would pass even if the user took no action.
76   SECURITY_CHECK(frame.Loader().GetDocumentLoader()->GetNavigationType() !=
77                  kWebNavigationTypeBackForward);
78 
79   // We only allow text fragment anchors for user navigations, e.g. link
80   // clicks, omnibox navigations, no script navigations.
81   if (!frame.Loader().GetDocumentLoader()->HadTransientActivation() &&
82       !frame.Loader().GetDocumentLoader()->IsBrowserInitiated()) {
83     return false;
84   }
85 
86   // Allow same-document navigations only if they are browser initiated, e.g.
87   // same-document bookmarks.
88   if (same_document_navigation) {
89     return frame.Loader()
90         .GetDocumentLoader()
91         ->LastSameDocumentNavigationWasBrowserInitiated();
92   }
93 
94   // Allow text fragments on same-origin initiated navigations.
95   if (frame.Loader().GetDocumentLoader()->IsSameOriginNavigation())
96     return true;
97 
98   // Otherwise, for cross origin initiated navigations, we only allow text
99   // fragments if the frame is not script accessible by another frame, i.e. no
100   // cross origin iframes or window.open.
101   if (frame.Tree().Parent() || frame.GetPage()->RelatedPages().size())
102     return false;
103 
104   return true;
105 }
106 
107 }  // namespace
108 
TryCreateFragmentDirective(const KURL & url,LocalFrame & frame,bool same_document_navigation,bool should_scroll)109 TextFragmentAnchor* TextFragmentAnchor::TryCreateFragmentDirective(
110     const KURL& url,
111     LocalFrame& frame,
112     bool same_document_navigation,
113     bool should_scroll) {
114   DCHECK(RuntimeEnabledFeatures::TextFragmentIdentifiersEnabled(
115       frame.GetDocument()));
116 
117   if (!frame.GetDocument()->GetFragmentDirective())
118     return nullptr;
119 
120   // Avoid invoking the text fragment for history or reload navigations as
121   // they'll be clobbered by scroll restoration; this prevents a transient
122   // scroll as well as user gesture issues; see https://crbug.com/1042986 for
123   // details.
124   auto navigation_type =
125       frame.Loader().GetDocumentLoader()->GetNavigationType();
126   if (navigation_type == kWebNavigationTypeBackForward ||
127       navigation_type == kWebNavigationTypeReload) {
128     return nullptr;
129   }
130 
131   if (!CheckSecurityRestrictions(frame, same_document_navigation))
132     return nullptr;
133 
134   Vector<TextFragmentSelector> selectors;
135 
136   if (!ParseTextDirective(frame.GetDocument()->GetFragmentDirective(),
137                           &selectors)) {
138     UseCounter::Count(frame.GetDocument(),
139                       WebFeature::kInvalidFragmentDirective);
140     return nullptr;
141   }
142 
143   return MakeGarbageCollected<TextFragmentAnchor>(selectors, frame,
144                                                   should_scroll);
145 }
146 
TextFragmentAnchor(const Vector<TextFragmentSelector> & text_fragment_selectors,LocalFrame & frame,bool should_scroll)147 TextFragmentAnchor::TextFragmentAnchor(
148     const Vector<TextFragmentSelector>& text_fragment_selectors,
149     LocalFrame& frame,
150     bool should_scroll)
151     : frame_(&frame),
152       should_scroll_(should_scroll),
153       metrics_(MakeGarbageCollected<TextFragmentAnchorMetrics>(
154           frame_->GetDocument())) {
155   DCHECK(!text_fragment_selectors.IsEmpty());
156   DCHECK(frame_->View());
157 
158   metrics_->DidCreateAnchor(text_fragment_selectors.size());
159 
160   text_fragment_finders_.ReserveCapacity(text_fragment_selectors.size());
161   for (TextFragmentSelector selector : text_fragment_selectors)
162     text_fragment_finders_.emplace_back(*this, selector);
163 }
164 
Invoke()165 bool TextFragmentAnchor::Invoke() {
166   if (element_fragment_anchor_) {
167     DCHECK(search_finished_);
168     // We need to keep this TextFragmentAnchor alive if we're proxying an
169     // element fragment anchor.
170     return true;
171   }
172 
173   // If we're done searching, return true if this hasn't been dismissed yet so
174   // that this is kept alive.
175   if (search_finished_)
176     return !dismissed_;
177 
178   frame_->GetDocument()->Markers().RemoveMarkersOfTypes(
179       DocumentMarker::MarkerTypes::TextFragment());
180 
181   // TODO(bokan): Once BlockHTMLParserOnStyleSheets is launched, there won't be
182   // a way for the user to scroll before we invoke and scroll the anchor. We
183   // should confirm if we can remove tracking this after that point or if we
184   // need a replacement metric.
185   if (user_scrolled_ && !did_scroll_into_view_)
186     metrics_->ScrollCancelled();
187 
188   first_match_needs_scroll_ = should_scroll_ && !user_scrolled_;
189 
190   {
191     // FindMatch might cause scrolling and set user_scrolled_ so reset it when
192     // it's done.
193     base::AutoReset<bool> reset_user_scrolled(&user_scrolled_, user_scrolled_);
194 
195     metrics_->ResetMatchCount();
196     for (auto& finder : text_fragment_finders_)
197       finder.FindMatch(*frame_->GetDocument());
198   }
199 
200   if (frame_->GetDocument()->IsLoadCompleted())
201     DidFinishSearch();
202 
203   // We return true to keep this anchor alive as long as we need another invoke,
204   // are waiting to be dismissed, or are proxying an element fragment anchor.
205   return !search_finished_ || !dismissed_ || element_fragment_anchor_;
206 }
207 
Installed()208 void TextFragmentAnchor::Installed() {}
209 
DidScroll(mojom::blink::ScrollType type)210 void TextFragmentAnchor::DidScroll(mojom::blink::ScrollType type) {
211   if (!IsExplicitScrollType(type))
212     return;
213 
214   user_scrolled_ = true;
215 }
216 
PerformPreRafActions()217 void TextFragmentAnchor::PerformPreRafActions() {
218   if (element_fragment_anchor_) {
219     element_fragment_anchor_->Installed();
220     element_fragment_anchor_->Invoke();
221     element_fragment_anchor_->PerformPreRafActions();
222     element_fragment_anchor_ = nullptr;
223   }
224 }
225 
Trace(Visitor * visitor)226 void TextFragmentAnchor::Trace(Visitor* visitor) {
227   visitor->Trace(frame_);
228   visitor->Trace(element_fragment_anchor_);
229   visitor->Trace(metrics_);
230   FragmentAnchor::Trace(visitor);
231 }
232 
DidFindMatch(const EphemeralRangeInFlatTree & range)233 void TextFragmentAnchor::DidFindMatch(const EphemeralRangeInFlatTree& range) {
234   if (search_finished_)
235     return;
236 
237   // TODO(nburris): Determine what we should do with overlapping text matches.
238   // This implementation drops a match if it overlaps a previous match, since
239   // overlapping ranges are likely unintentional by the URL creator and could
240   // therefore indicate that the page text has changed.
241   if (!frame_->GetDocument()
242            ->Markers()
243            .MarkersIntersectingRange(
244                range, DocumentMarker::MarkerTypes::TextFragment())
245            .IsEmpty()) {
246     return;
247   }
248 
249   bool needs_style_and_layout = false;
250 
251   // Apply :target to the first match
252   if (!did_find_match_) {
253     ApplyTargetToCommonAncestor(range);
254     needs_style_and_layout = true;
255   }
256 
257   // Activate any find-in-page activatable display-locks in the ancestor
258   // chain.
259   if (DisplayLockUtilities::ActivateFindInPageMatchRangeIfNeeded(range)) {
260     // Since activating a lock dirties layout, we need to make sure it's clean
261     // before computing the text rect below.
262     needs_style_and_layout = true;
263     // TODO(crbug.com/1041942): It is possible and likely that activation
264     // signal causes script to resize something on the page. This code here
265     // should really yield until the next frame to give script an opportunity
266     // to run.
267   }
268 
269   if (needs_style_and_layout) {
270     frame_->GetDocument()->UpdateStyleAndLayout(
271         DocumentUpdateReason::kFindInPage);
272   }
273 
274   metrics_->DidFindMatch(PlainText(range));
275   did_find_match_ = true;
276 
277   if (first_match_needs_scroll_) {
278     first_match_needs_scroll_ = false;
279 
280     PhysicalRect bounding_box(ComputeTextRect(range));
281 
282     // Set the bounding box height to zero because we want to center the top of
283     // the text range.
284     bounding_box.SetHeight(LayoutUnit());
285 
286     DCHECK(range.Nodes().begin() != range.Nodes().end());
287 
288     Node& node = *range.Nodes().begin();
289 
290     DCHECK(node.GetLayoutObject());
291 
292     PhysicalRect scrolled_bounding_box =
293         node.GetLayoutObject()->ScrollRectToVisible(
294             bounding_box, ScrollAlignment::CreateScrollIntoViewParams(
295                               ScrollAlignment::CenterAlways(),
296                               ScrollAlignment::CenterAlways(),
297                               mojom::blink::ScrollType::kProgrammatic));
298     did_scroll_into_view_ = true;
299 
300     if (AXObjectCache* cache = frame_->GetDocument()->ExistingAXObjectCache())
301       cache->HandleScrolledToAnchor(&node);
302 
303     metrics_->DidScroll();
304 
305     // We scrolled the text into view if the main document scrolled or the text
306     // bounding box changed, i.e. if it was scrolled in a nested scroller.
307     // TODO(nburris): The rect returned by ScrollRectToVisible,
308     // scrolled_bounding_box, should be in frame coordinates in which case
309     // just checking its location would suffice, but there is a bug where it is
310     // actually in document coordinates and therefore does not change with a
311     // main document scroll.
312     if (!frame_->View()->GetScrollableArea()->GetScrollOffset().IsZero() ||
313         scrolled_bounding_box.offset != bounding_box.offset) {
314       metrics_->DidNonZeroScroll();
315     }
316   }
317   EphemeralRange dom_range =
318       EphemeralRange(ToPositionInDOMTree(range.StartPosition()),
319                      ToPositionInDOMTree(range.EndPosition()));
320   frame_->GetDocument()->Markers().AddTextFragmentMarker(dom_range);
321 }
322 
DidFindAmbiguousMatch()323 void TextFragmentAnchor::DidFindAmbiguousMatch() {
324   metrics_->DidFindAmbiguousMatch();
325 }
326 
DidFinishSearch()327 void TextFragmentAnchor::DidFinishSearch() {
328   DCHECK(!search_finished_);
329   search_finished_ = true;
330 
331   metrics_->ReportMetrics();
332 
333   if (!did_find_match_) {
334     dismissed_ = true;
335 
336     DCHECK(!element_fragment_anchor_);
337     element_fragment_anchor_ = ElementFragmentAnchor::TryCreate(
338         frame_->GetDocument()->Url(), *frame_, should_scroll_);
339     if (element_fragment_anchor_) {
340       // Schedule a frame so we can invoke the element anchor in
341       // PerformPreRafActions.
342       frame_->GetPage()->GetChromeClient().ScheduleAnimation(frame_->View());
343     }
344   }
345 }
346 
Dismiss()347 bool TextFragmentAnchor::Dismiss() {
348   // To decrease the likelihood of the user dismissing the highlight before
349   // seeing it, we only dismiss the anchor after search_finished_, at which
350   // point we've scrolled it into view or the user has started scrolling the
351   // page.
352   if (!search_finished_)
353     return false;
354 
355   if (!did_find_match_ || dismissed_)
356     return true;
357 
358   DCHECK(!should_scroll_ || did_scroll_into_view_ || user_scrolled_);
359 
360   frame_->GetDocument()->Markers().RemoveMarkersOfTypes(
361       DocumentMarker::MarkerTypes::TextFragment());
362   dismissed_ = true;
363   metrics_->Dismissed();
364 
365   return dismissed_;
366 }
367 
ApplyTargetToCommonAncestor(const EphemeralRangeInFlatTree & range)368 void TextFragmentAnchor::ApplyTargetToCommonAncestor(
369     const EphemeralRangeInFlatTree& range) {
370   Node* common_node = range.CommonAncestorContainer();
371   while (common_node && common_node->getNodeType() != Node::kElementNode) {
372     common_node = common_node->parentNode();
373   }
374 
375   DCHECK(common_node);
376   if (common_node) {
377     auto* target = DynamicTo<Element>(common_node);
378     frame_->GetDocument()->SetCSSTarget(target);
379   }
380 }
381 
382 }  // namespace blink
383