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