Index: ios/chrome/browser/ui/contextual_search/contextual_search_controller.mm |
diff --git a/ios/chrome/browser/ui/contextual_search/contextual_search_controller.mm b/ios/chrome/browser/ui/contextual_search/contextual_search_controller.mm |
new file mode 100644 |
index 0000000000000000000000000000000000000000..39bc9d8a9e315e4b90e22b306abb5b00f051c6df |
--- /dev/null |
+++ b/ios/chrome/browser/ui/contextual_search/contextual_search_controller.mm |
@@ -0,0 +1,1633 @@ |
+// Copyright 2014 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. |
+ |
+#import "ios/chrome/browser/ui/contextual_search/contextual_search_controller.h" |
+ |
+#include <memory> |
+#include <utility> |
+ |
+#include "base/ios/ios_util.h" |
+#import "base/ios/weak_nsobject.h" |
+#include "base/json/json_reader.h" |
+#include "base/logging.h" |
+#import "base/mac/bind_objc_block.h" |
+#include "base/mac/foundation_util.h" |
+#include "base/mac/scoped_block.h" |
+#include "base/mac/scoped_nsobject.h" |
+#include "base/strings/sys_string_conversions.h" |
+#include "base/strings/utf_string_conversions.h" |
+#include "base/time/time.h" |
+#include "base/values.h" |
+#include "components/google/core/browser/google_util.h" |
+#include "components/search_engines/template_url_service.h" |
+#include "ios/chrome/browser/application_context.h" |
+#import "ios/chrome/browser/procedural_block_types.h" |
+#import "ios/chrome/browser/tabs/tab.h" |
+#import "ios/chrome/browser/ui/commands/UIKit+ChromeExecuteCommand.h" |
+#import "ios/chrome/browser/ui/commands/generic_chrome_command.h" |
+#include "ios/chrome/browser/ui/commands/ios_command_ids.h" |
+#include "ios/chrome/browser/ui/contextual_search/contextual_search_context.h" |
+#include "ios/chrome/browser/ui/contextual_search/contextual_search_delegate.h" |
+#import "ios/chrome/browser/ui/contextual_search/contextual_search_header_view.h" |
+#import "ios/chrome/browser/ui/contextual_search/contextual_search_highlighter_view.h" |
+#import "ios/chrome/browser/ui/contextual_search/contextual_search_metrics.h" |
+#import "ios/chrome/browser/ui/contextual_search/contextual_search_panel_protocols.h" |
+#import "ios/chrome/browser/ui/contextual_search/contextual_search_panel_view.h" |
+#import "ios/chrome/browser/ui/contextual_search/contextual_search_promo_view.h" |
+#import "ios/chrome/browser/ui/contextual_search/contextual_search_results_view.h" |
+#include "ios/chrome/browser/ui/contextual_search/contextual_search_web_state_observer.h" |
+#import "ios/chrome/browser/ui/contextual_search/js_contextual_search_manager.h" |
+#import "ios/chrome/browser/ui/contextual_search/touch_to_search_permissions_mediator.h" |
+#import "ios/chrome/browser/ui/contextual_search/window_gesture_observer.h" |
+#import "ios/chrome/browser/ui/show_privacy_settings_util.h" |
+#include "ios/chrome/browser/ui/ui_util.h" |
+#import "ios/chrome/browser/ui/uikit_ui_util.h" |
+#import "ios/chrome/browser/web/dom_altering_lock.h" |
+#include "ios/chrome/common/string_util.h" |
+#include "ios/chrome/grit/ios_strings.h" |
+#import "ios/third_party/material_components_ios/src/components/Snackbar/src/MaterialSnackbar.h" |
+#include "ios/web/public/browser_state.h" |
+#include "ios/web/public/load_committed_details.h" |
+#include "ios/web/public/referrer.h" |
+#import "ios/web/public/web_state/crw_web_view_proxy.h" |
+#import "ios/web/public/web_state/crw_web_view_scroll_view_proxy.h" |
+#import "ios/web/public/web_state/js/crw_js_injection_receiver.h" |
+#include "ios/web/public/web_state/web_state.h" |
+#include "ios/web/public/web_state/web_state_observer.h" |
+#include "ui/base/l10n/l10n_util.h" |
+#include "ui/base/l10n/l10n_util_mac.h" |
+ |
+// Returns |value| clamped so that min <= value <= max |
+#define CLAMP(min, value, max) MAX(min, MIN(value, max)) |
+ |
+namespace { |
+// command prefix for injected JavaScript. |
+const std::string kCommandPrefix = "contextualSearch"; |
+ |
+// Distance from edges of frame when scrolling to show selection. |
+const CGFloat kYScrollMargin = 30.0; |
+const CGFloat kXScrollMargin = 10.0; |
+ |
+// Delay to check if there is a DOM modification (in second). |
+// If delay is too short, JavaScript won't have time to handle the event and the |
+// DOM tree will be modified after the highlight. |
+// If delay is too long, the user experience will be degraded. |
+// Experiments on some websites (e.g. belgianrails.be) show that delay must be |
+// over 0.35 second. |
+// Timeline is as follow: |
+// t: tap happens, |
+// t + doubleTapDelay (t1): tap is triggered |
+// t1: JavaScript handles tap, |
+// t1 + delta1: DOM may be modified, |
+// t1 + kDOMModificationDelaySeconds: |handleTapFrom:| starts handling tap, |
+// t1 + kDOMModificationDelaySeconds + delta2: JavaScript handleTap is called. |
+// |
+// The delay between DOM mutation and contextual search tap handling is really |
+// kDOMModificationDelaySeconds + delta2 - delta1. |
+// To handle this random delta value, the timeout passed to JavaScript is |
+// doubled |
+// The body touch event timeout must include the double tap delay. |
+const CGFloat kDOMModificationDelaySeconds = 0.1; |
+const CGFloat kDOMModificationDelayForJavaScriptMilliseconds = |
+ 2 * 1000 * kDOMModificationDelaySeconds; |
+ |
+// The touch delay disables CS on many sites, so for now it is disabled. |
+// The previous value used was: |
+// kDOMModificationDelayForJavaScriptMilliseconds + 300 |
+const CGFloat kBodyTouchDelayForJavaScriptMilliseconds = 0; |
+ |
+// After a dismiss, do not allow retrigger before a delay to prevent triggering |
+// on double tap, and to prevent retrigger on the same event. |
+const NSTimeInterval kPreventTriggerAfterDismissDelaySeconds = 0.3; |
+ |
+CGRect StringValueToRect(NSString* rectString) { |
+ double rectTop, rectBottom, rectLeft, rectRight; |
+ NSArray* items = [rectString componentsSeparatedByString:@" "]; |
+ if ([items count] != 4) { |
+ return CGRectNull; |
+ } |
+ rectTop = [[items objectAtIndex:0] doubleValue]; |
+ rectBottom = [[items objectAtIndex:1] doubleValue]; |
+ rectLeft = [[items objectAtIndex:2] doubleValue]; |
+ rectRight = [[items objectAtIndex:3] doubleValue]; |
+ if (isnan(rectTop) || isinf(rectTop) || isnan(rectBottom) || |
+ isinf(rectBottom) || isnan(rectLeft) || isinf(rectLeft) || |
+ isnan(rectRight) || isinf(rectRight) || rectRight <= rectLeft || |
+ rectBottom <= rectTop) { |
+ return CGRectNull; |
+ } |
+ CGRect rect = |
+ CGRectMake(rectLeft, rectTop, rectRight - rectLeft, rectBottom - rectTop); |
+ return rect; |
+} |
+ |
+NSArray* StringValueToRectArray(const std::string& list) { |
+ NSString* nsList = base::SysUTF8ToNSString(list); |
+ NSMutableArray* rectsArray = [[[NSMutableArray alloc] init] autorelease]; |
+ NSArray* items = [nsList componentsSeparatedByString:@","]; |
+ for (NSString* item : items) { |
+ CGRect rect = StringValueToRect(item); |
+ if (CGRectIsNull(rect)) { |
+ return nil; |
+ } |
+ [rectsArray addObject:[NSValue valueWithCGRect:rect]]; |
+ } |
+ return rectsArray; |
+} |
+ |
+} // namespace |
+ |
+@interface ContextualSearchController ()<DOMAltering, |
+ CRWWebViewScrollViewProxyObserver, |
+ UIGestureRecognizerDelegate, |
+ ContextualSearchHighlighterDelegate, |
+ ContextualSearchPromoViewDelegate, |
+ ContextualSearchPanelMotionObserver, |
+ ContextualSearchPanelTapHandler, |
+ ContextualSearchPreloadChecker, |
+ ContextualSearchTabPromoter, |
+ ContextualSearchWebStateDelegate, |
+ TouchToSearchPermissionsChangeAudience> |
+ |
+// Controller delegate for the controller to call back to. |
+@property(nonatomic, readwrite, assign) id<ContextualSearchControllerDelegate> |
+ controllerDelegate; |
+ |
+// Permissions interface for this feature. Property is readwrite for testing. |
+@property(nonatomic, readwrite, retain) |
+ TouchToSearchPermissionsMediator* permissions; |
+ |
+// Synchronous method executed by -asynchronouslyEnableContextualSearch: |
+- (void)doEnableContextualSearch:(BOOL)enabled; |
+ |
+// Handler for injected JavaScript callbacks. |
+- (BOOL)handleScriptCommand:(const base::DictionaryValue&)JSONCommand; |
+ |
+// Handle the selection change event if the DOM lock is acquired. |
+// |selection| is the currently selected text in the webview. |
+// if |updated| is true, then the selection changed by the user moving one of |
+// the selection handles (not making a new selection). |
+// If |selectionValid| is false, the selection contains invalid chars or element |
+// and TTS should be dismissed. If selection is invalid, |selection| is empty. |
+- (void)handleSelectionChanged:(const std::string&)selection |
+ selectionUpdated:(BOOL)update |
+ selectionValid:(BOOL)selectionValid; |
+ |
+// Action for the tap gesture recognizer. |
+- (void)handleTapFrom:(UIGestureRecognizer*)gestureRecognizer; |
+ |
+// Handle a tap on a web view at |point|, extracting contextual search |
+// information from the tapped word and surrounding text. |
+- (void)handleTapAtPoint:(CGPoint)point; |
+ |
+// Initialize the contextual search JavaScript. |
+- (void)initializeWebViewForContextualSearch; |
+ |
+// Update the webViewProxy for the current tab to enable/disable scroll view |
+// observation. |
+- (void)updateWebViewProxy:(id<CRWWebViewProxy>)webViewProxy; |
+ |
+// Updates the UI to match the current state, setting the text label content |
+// if there is a current search context, and setting the panel state. |
+- (void)updateUI; |
+ |
+// Updates the UI for a resolved search. |
+- (void)updateForResolvedSearch: |
+ (ContextualSearchDelegate::SearchResolution)resolution; |
+ |
+// State changes. |
+// Set the state of the panel, given |reason|. Handles metrics updates. |
+- (void)setState:(ContextualSearch::PanelState)state |
+ reason:(ContextualSearch::StateChangeReason)reason; |
+ |
+// Dismiss pane for |reason|, invoking |completionHandler|, if any, after |
+// clearing any existing highlighted text in the webview, and finally releasing |
+// the DOM lock. |
+- (void) |
+dismissPaneWithJavascriptCompletionHandler:(ProceduralBlock)completionHandler |
+ reason:(ContextualSearch::StateChangeReason) |
+ reason; |
+ |
+// Clean-up the web state (release lock, clear highlight...) in case of a |
+// dismiss. |
+- (void)cleanUpWebStateForDismissWithCompletion: |
+ (ProceduralBlock)completionHandler; |
+ |
+// Convenience method for dismissing the pane with no completion handler. |
+- (void)dismissPane:(ContextualSearch::StateChangeReason)reason; |
+ |
+// Peek (show at the bottom of the window) the pane for |reason|. |
+- (void)peekPane:(ContextualSearch::StateChangeReason)reason; |
+ |
+// Preview the pane (covering kPreviewingDisplayRatio of the webview) for |
+// |reason|. |
+- (void)previewPane:(ContextualSearch::StateChangeReason)reason; |
+ |
+// Cover the pane (covering the entire webview) for |reason|. |
+- (void)coverPane:(ContextualSearch::StateChangeReason)reason; |
+ |
+// Scroll the webview to show the highlighted text. |
+// Scroll the minimal distance to put |_highlightBoundingRect| at |
+// |kYScrollMargin| from top and bottom edges and |kXScrollMargin| from left and |
+// right edges. |
+// Overflow policy : |
+// - horizontal: center |_highlightBoundingRect|, |
+// - vertical: put |_highlightBoundingRect| at |kYScrollMargin| from top edge. |
+- (void)scrollToShowSelection:(CRWWebViewScrollViewProxy*)scrollView; |
+ |
+// Creates, enables or disables the dismiss recognizer based on state_. |
+- (void)updateDismissRecognizer; |
+ |
+@end |
+ |
+@implementation ContextualSearchController { |
+ // Permissions interface for this feature. |
+ base::scoped_nsobject<TouchToSearchPermissionsMediator> _permissions; |
+ |
+ // WebState for the tab this object is attached to. |
+ web::WebState* _webState; |
+ |
+ // Access to the web view from |_webState|. |
+ base::scoped_nsprotocol<id<CRWWebViewProxy>> _webViewProxy; |
+ |
+ // Observer for |_webState|. |
+ std::unique_ptr<ContextualSearchWebStateObserver> _webStateObserver; |
+ |
+ // Observer for search tab's web state. |
+ std::unique_ptr<ContextualSearchWebStateObserver> _searchTabWebStateObserver; |
+ |
+ // Object that manages find_in_page.js injection into the web view. |
+ base::WeakNSObject<JsContextualSearchManager> _contextualSearchJsManager; |
+ |
+ // Gesture reccognizer for contextual search taps. |
+ base::scoped_nsobject<UITapGestureRecognizer> _tapRecognizer; |
+ |
+ // Gesture reccognizer for double tap. It is used to prevent |_tapRecognizer| |
+ // from firing if there is a double tap on the web view. It is disabled when |
+ // the panel is displayed, since any tap will dismiss the panel in that case. |
+ base::scoped_nsobject<UITapGestureRecognizer> _doubleTapRecognizer; |
+ |
+ // Gesture recognizer for long-tap copy. |
+ base::scoped_nsobject<UILongPressGestureRecognizer> _copyGestureRecognizer; |
+ |
+ // Gesture recognizer to detect taps outside of the CS interface that would |
+ // cause it to dismiss. |
+ base::scoped_nsobject<WindowGestureObserver> _dismissRecognizer; |
+ |
+ // Context information retrieved from a search tap. |
+ std::shared_ptr<ContextualSearchContext> _searchContext; |
+ |
+ // Resolved search information generated from the context or text selection. |
+ ContextualSearchDelegate::SearchResolution _resolvedSearch; |
+ |
+ // Delegate for fetching search information. |
+ std::unique_ptr<ContextualSearchDelegate> _delegate; |
+ |
+ // The panel view controlled by this object; it is created externally and |
+ // owned by its superview. There is no guarantee about its lifetime. |
+ base::WeakNSObject<ContextualSearchPanelView> _panelView; |
+ |
+ // The view containing the highlighting of the search terms. |
+ base::WeakNSObject<ContextualSearchHighlighterView> _contextualHighlightView; |
+ |
+ // Content view displayed in the peeking section of the panel. |
+ base::scoped_nsobject<ContextualSearchHeaderView> _headerView; |
+ |
+ // Vertical constraints for layout of the search tab. |
+ base::scoped_nsobject<NSArray> _searchTabVerticalConstraints; |
+ |
+ // Container view for the opt-out promo and the search tab view. |
+ base::scoped_nsobject<ContextualSearchResultsView> _searchResultsView; |
+ |
+ // View for the opt-out promo. |
+ base::scoped_nsobject<ContextualSearchPromoView> _promoView; |
+ |
+ // The tab that should be used as the opener for the search tab. |
+ Tab* _opener; |
+ |
+ // YES if a cancel event was received since last tap, meaning the current tap |
+ // must not result in a search. |
+ BOOL _currentTapCancelled; |
+ |
+ // The current selection text. |
+ std::string _selectedText; |
+ |
+ // Boolean to track if the current WebState is enabled (has |
+ // gesture recognizers and DOM lock set up). |
+ BOOL _webStateEnabled; |
+ |
+ // Boolean to distinguish selection-clearing taps on the webview from |
+ // those on other UI elements. |
+ BOOL _webViewTappedWithSelection; |
+ |
+ // Metrics tracking variables and flags. |
+ // Time the tap handler fires. The delay of doubleTap is not counted. |
+ base::Time _tapTime; |
+ // Has the user entered the previewing/covering state yet for the |
+ // current search? |
+ BOOL _enteredPreviewing; |
+ BOOL _enteredCovering; |
+ // Has the search results content been visible for the current search? |
+ BOOL _resultsVisible; |
+ // Has the user exited the peeking/previewing/covering state yet for the |
+ // current search? |
+ BOOL _exitedPeeking; |
+ BOOL _exitedPreviewing; |
+ BOOL _exitedCovering; |
+ // Was the first run flow invoked during this search? |
+ BOOL _searchInvolvedFirstRun; |
+ // Did the first run panel become visible during this search? |
+ BOOL _firstRunPanelBecameVisible; |
+ // Was the search triggered by a long-press selection? Unlike other metrics- |
+ // related flags, this is not reset when a search ends; instead it is set |
+ // when a new search is started. |
+ BOOL _searchTriggeredBySelection; |
+ // Has the current search used SERP navigation (tapped on a link on the |
+ // search results page)? |
+ BOOL _usedSERPNavigation; |
+ // Boolean to track if the script has been injected in the current page. This |
+ // is a faster check than asking the JS controller. |
+ BOOL _isScriptInjected; |
+ |
+ // Boolean to track if the UIMenuControllerWillShowMenuNotification is |
+ // observed (to prevent double observation). |
+ BOOL _observingActionMenu; |
+ // Boolean to track if the current search is triggered by selection, and |
+ // action menu should be disabled. |
+ BOOL _preventActionMenu; |
+ // Boolean to track if a new text selection has been made (as opposed to an |
+ // existing one being changed) which will trigger the appearance of the |
+ // panel. |
+ BOOL _newSelectionDisplaying; |
+ |
+ // Boolean to temporarly disable preloading of search tab. |
+ BOOL _preventPreload; |
+ |
+ // Boolean to track if the search term has been resolved. |
+ BOOL _searchTermResolved; |
+ |
+ // True when closed has been called and contextual search controller |
+ // has been destroyed. |
+ BOOL _closed; |
+ |
+ // When view is resized, JavaScript and UIView sizes are not updated at the |
+ // same time. Computing a scroll delta to make selection visible in these |
+ // conditions will likely scroll to a random position. |
+ BOOL _preventScrollToShowSelection; |
+ |
+ // The time of the last dismiss. |
+ base::scoped_nsobject<NSDate> _lastDismiss; |
+} |
+ |
+@synthesize enabled = _enabled; |
+@synthesize controllerDelegate = _controllerDelegate; |
+@synthesize webState = _webState; |
+ |
+- (instancetype)initWithBrowserState:(ios::ChromeBrowserState*)browserState |
+ delegate:(id<ContextualSearchControllerDelegate>) |
+ delegate { |
+ if ((self = [super init])) { |
+ _permissions.reset([[TouchToSearchPermissionsMediator alloc] |
+ initWithBrowserState:browserState]); |
+ [_permissions setAudience:self]; |
+ |
+ self.controllerDelegate = delegate; |
+ |
+ // Set up the web state observer. This lasts as long as this object does, |
+ // but it will observe and un-observe the web tabs as it changes over time. |
+ _webStateObserver.reset(new ContextualSearchWebStateObserver(self)); |
+ |
+ _copyGestureRecognizer.reset([[UILongPressGestureRecognizer alloc] |
+ initWithTarget:self |
+ action:@selector(handleLongPressFrom:)]); |
+ |
+ base::WeakNSObject<ContextualSearchController> weakself(self); |
+ auto callback = base::BindBlock( |
+ ^(ContextualSearchDelegate::SearchResolution resolution) { |
+ [weakself updateForResolvedSearch:resolution]; |
+ }); |
+ |
+ _delegate.reset(new ContextualSearchDelegate(browserState, callback)); |
+ } |
+ return self; |
+} |
+ |
+- (TouchToSearchPermissionsMediator*)permissions { |
+ return _permissions; |
+} |
+ |
+- (void)setPermissions:(TouchToSearchPermissionsMediator*)permissions { |
+ _permissions.reset(permissions); |
+} |
+ |
+- (ContextualSearchPanelView*)panel { |
+ return _panelView; |
+} |
+ |
+- (void)setPanel:(ContextualSearchPanelView*)panel { |
+ DCHECK(!_panelView); |
+ DCHECK(panel); |
+ |
+ // Save the new panel, set up observation and delegation relationships. |
+ _panelView.reset(panel); |
+ [_panelView addMotionObserver:self]; |
+ [_dismissRecognizer setViewToExclude:_panelView]; |
+ |
+ // Create new subviews. |
+ NSMutableArray* panelContents = [NSMutableArray arrayWithCapacity:3]; |
+ |
+ _headerView.reset([[ContextualSearchHeaderView alloc] |
+ initWithHeight:[_panelView configuration].peekingHeight]); |
+ [_headerView addGestureRecognizer:_copyGestureRecognizer]; |
+ [_headerView setTapHandler:self]; |
+ |
+ [panelContents addObject:_headerView]; |
+ |
+ if (self.permissions.preferenceState == TouchToSearch::UNDECIDED) { |
+ _promoView.reset([[ContextualSearchPromoView alloc] initWithFrame:CGRectZero |
+ delegate:self]); |
+ [panelContents addObject:_promoView]; |
+ } |
+ |
+ _searchResultsView.reset( |
+ [[ContextualSearchResultsView alloc] initWithFrame:CGRectZero]); |
+ [_searchResultsView setPromoter:self]; |
+ [_searchResultsView setPreloadChecker:self]; |
+ [panelContents addObject:_searchResultsView]; |
+ |
+ [_panelView addContentViews:panelContents]; |
+} |
+ |
+- (void)enableContextualSearch:(BOOL)enabled { |
+ // Asynchronously enables contextual search, so that some preferences |
+ // (UIAccessibilityIsVoiceOverRunning(), for example) have time to synchronize |
+ // with their own notifications. |
+ base::WeakNSObject<ContextualSearchController> weakSelf(self); |
+ dispatch_async(dispatch_get_main_queue(), ^{ |
+ [weakSelf doEnableContextualSearch:enabled]; |
+ }); |
+} |
+ |
+- (void)doEnableContextualSearch:(BOOL)enabled { |
+ enabled = enabled && [self.permissions canEnable]; |
+ |
+ BOOL changing = _enabled != enabled; |
+ if (changing) { |
+ if (!enabled) { |
+ [self dismissPane:ContextualSearch::RESET]; |
+ } |
+ _enabled = enabled; |
+ [self enableCurrentWebState]; |
+ } |
+} |
+ |
+- (void)updateWebViewProxy:(id<CRWWebViewProxy>)webViewProxy { |
+ if (_webViewProxy) { |
+ [[_webViewProxy scrollViewProxy] removeObserver:self]; |
+ } |
+ _webViewProxy.reset([webViewProxy retain]); |
+ if (_webViewProxy) { |
+ [[_webViewProxy scrollViewProxy] addObserver:self]; |
+ } |
+} |
+ |
+- (void)setTab:(Tab*)tab { |
+ [self setWebState:tab.webState]; |
+ [_searchResultsView setOpener:tab]; |
+} |
+ |
+- (void)setWebState:(web::WebState*)webState { |
+ [self disconnectWebState]; |
+ if (webState) { |
+ _contextualSearchJsManager.reset(static_cast<JsContextualSearchManager*>( |
+ [webState->GetJSInjectionReceiver() |
+ instanceOfClass:[JsContextualSearchManager class]])); |
+ _webState = webState; |
+ _webStateObserver->ObserveWebState(webState); |
+ [self updateWebViewProxy:webState->GetWebViewProxy()]; |
+ [self enableCurrentWebState]; |
+ } else { |
+ _webState = nullptr; |
+ } |
+} |
+ |
+- (void)enableCurrentWebState { |
+ if (![self webState]) |
+ return; |
+ if (_enabled && [self webState]->ContentIsHTML()) { |
+ if (!_webStateEnabled) { |
+ DOMAlteringLock::CreateForWebState([self webState]); |
+ |
+ base::WeakNSObject<ContextualSearchController> weakSelf(self); |
+ auto callback = |
+ base::BindBlock(^bool(const base::DictionaryValue& JSON, |
+ const GURL& originURL, bool userIsInteracting) { |
+ base::scoped_nsobject<ContextualSearchController> strongSelf( |
+ [weakSelf retain]); |
+ // |originURL| and |isInteracting| aren't used. |
+ return [strongSelf handleScriptCommand:JSON]; |
+ }); |
+ [self webState]->AddScriptCommandCallback(callback, kCommandPrefix); |
+ |
+ // |_doubleTapRecognizer| should be added to the web view before |
+ // |_tapRecognizer| so |_tapRecognizer| can require it to fail. |
+ _doubleTapRecognizer.reset([[UITapGestureRecognizer alloc] |
+ initWithTarget:self |
+ action:@selector(ignoreTap:)]); |
+ [_doubleTapRecognizer setDelegate:self]; |
+ [_doubleTapRecognizer setNumberOfTapsRequired:2]; |
+ [_webViewProxy addGestureRecognizer:_doubleTapRecognizer]; |
+ |
+ _tapRecognizer.reset([[UITapGestureRecognizer alloc] |
+ initWithTarget:self |
+ action:@selector(handleTapFrom:)]); |
+ [_tapRecognizer setDelegate:self]; |
+ [_webViewProxy addGestureRecognizer:_tapRecognizer]; |
+ |
+ // Make sure that |_tapRecogngizer| doesn't fire if the web view's other |
+ // non-single-finger non-single-tap recognizers fire. |
+ for (UIGestureRecognizer* recognizer in |
+ [[_tapRecognizer view] gestureRecognizers]) { |
+ if ([recognizer isKindOfClass:[UITapGestureRecognizer class]] && |
+ ([static_cast<UITapGestureRecognizer*>(recognizer) |
+ numberOfTapsRequired] > 1 || |
+ [static_cast<UITapGestureRecognizer*>(recognizer) |
+ numberOfTouchesRequired] > 1)) { |
+ [_tapRecognizer requireGestureRecognizerToFail:recognizer]; |
+ } |
+ } |
+ _webStateEnabled = YES; |
+ } |
+ |
+ [self initializeWebViewForContextualSearch]; |
+ } else { |
+ [self disableCurrentWebState]; |
+ } |
+} |
+ |
+- (void)disableCurrentWebState { |
+ if (_webStateEnabled) { |
+ if ([self webState]->ContentIsHTML()) { |
+ [self highlightRects:nil]; |
+ [_contextualHighlightView removeFromSuperview]; |
+ [_contextualSearchJsManager clearHighlight]; |
+ [_contextualSearchJsManager disableListeners]; |
+ } |
+ _webState->RemoveScriptCommandCallback(kCommandPrefix); |
+ DOMAlteringLock::FromWebState(_webState)->Release(self); |
+ [_webViewProxy removeGestureRecognizer:_tapRecognizer]; |
+ [_webViewProxy removeGestureRecognizer:_doubleTapRecognizer]; |
+ _webStateEnabled = NO; |
+ } |
+} |
+ |
+- (void)disconnectWebState { |
+ if (_webState) { |
+ _contextualSearchJsManager.reset(); |
+ _webStateObserver->ObserveWebState(nullptr); |
+ [self updateWebViewProxy:nil]; |
+ [self disableCurrentWebState]; |
+ } |
+} |
+ |
+- (void)updateDismissRecognizer { |
+ if (!_panelView) |
+ return; |
+ if (!_dismissRecognizer) { |
+ _dismissRecognizer.reset([[WindowGestureObserver alloc] |
+ initWithTarget:self |
+ action:@selector(handleWindowGesture:)]); |
+ [_dismissRecognizer setViewToExclude:_panelView]; |
+ [[_panelView window] addGestureRecognizer:_dismissRecognizer]; |
+ } |
+ |
+ [_dismissRecognizer |
+ setEnabled:[_panelView state] >= ContextualSearch::PEEKING]; |
+} |
+ |
+- (void)showLearnMore { |
+ [self dismissPane:ContextualSearch::UNKNOWN]; |
+ GURL learnMoreUrl = google_util::AppendGoogleLocaleParam( |
+ GURL(l10n_util::GetStringUTF8(IDS_IOS_CONTEXTUAL_SEARCH_LEARN_MORE_URL)), |
+ GetApplicationContext()->GetApplicationLocale()); |
+ [_controllerDelegate createTabFromContextualSearchController:learnMoreUrl]; |
+} |
+ |
+- (void)dealloc { |
+ [self close]; |
+ [super dealloc]; |
+} |
+ |
+- (void)handleWindowGesture:(UIGestureRecognizer*)recognizer { |
+ DCHECK(recognizer == _dismissRecognizer.get()); |
+ [self dismissPane:ContextualSearch::BASE_PAGE_TAP]; |
+} |
+ |
+- (BOOL)canExtractTapContext { |
+ web::URLVerificationTrustLevel trustLevel = web::kNone; |
+ GURL pageURL = [self webState]->GetCurrentURL(&trustLevel); |
+ return [self.permissions canExtractTapContextForURL:pageURL]; |
+} |
+ |
+- (void)initializeWebViewForContextualSearch { |
+ DCHECK(_webStateEnabled); |
+ [_contextualSearchJsManager inject]; |
+ _isScriptInjected = YES; |
+ [_contextualSearchJsManager |
+ enableEventListenersWithMutationDelay: |
+ kDOMModificationDelayForJavaScriptMilliseconds |
+ bodyTouchDelay: |
+ kBodyTouchDelayForJavaScriptMilliseconds]; |
+} |
+ |
+- (void)handleSelectionChanged:(const std::string&)selection |
+ selectionUpdated:(BOOL)updated |
+ selectionValid:(BOOL)selectionValid { |
+ if (!selectionValid) { |
+ [self dismissPane:ContextualSearch::INVALID_SELECTION]; |
+ return; |
+ } |
+ std::string selectedText = CleanStringForDisplay(selection, true); |
+ |
+ if (selectedText == _selectedText) |
+ return; |
+ _newSelectionDisplaying = !updated && !selectedText.empty(); |
+ _selectedText = selectedText; |
+ _searchContext.reset(); |
+ [self highlightRects:nil]; |
+ [_contextualSearchJsManager clearHighlight]; |
+ _delegate->CancelSearchTermRequest(); |
+ |
+ if (selectedText.length() == 0) { |
+ if (_webViewTappedWithSelection) { |
+ [self dismissPane:ContextualSearch::BASE_PAGE_TAP]; |
+ } |
+ } else { |
+ // TODO(crbug.com/546220): Detect and use actual page encoding. |
+ std::string encoding = "UTF-8"; |
+ |
+ _searchContext.reset( |
+ new ContextualSearchContext(selectedText, true, GURL(), encoding)); |
+ _searchTriggeredBySelection = YES; |
+ |
+ _preventPreload = YES; |
+ _delegate->PostSearchTermRequest(_searchContext); |
+ |
+ ContextualSearch::RecordSelectionIsValid(true); |
+ _preventActionMenu = YES; |
+ if (!_observingActionMenu) { |
+ _observingActionMenu = YES; |
+ [[NSNotificationCenter defaultCenter] |
+ addObserver:self |
+ selector:@selector(willShowMenuNotification) |
+ name:UIMenuControllerWillShowMenuNotification |
+ object:nil]; |
+ } |
+ [self peekPane:ContextualSearch::TEXT_SELECT_LONG_PRESS]; |
+ [_headerView |
+ setSearchTerm:base::SysUTF8ToNSString(selectedText) |
+ animated:[_panelView state] != ContextualSearch::DISMISSED]; |
+ } |
+ _webViewTappedWithSelection = NO; |
+} |
+ |
+- (BOOL)handleScriptCommand:(const base::DictionaryValue&)JSONCommand { |
+ std::string command; |
+ if (!JSONCommand.GetString("command", &command)) |
+ return NO; |
+ if (command == "contextualSearch.selectionChanged") { |
+ std::string selectedText; |
+ if (!JSONCommand.GetString("text", &selectedText)) |
+ return NO; |
+ bool selectionUpdated; |
+ if (!JSONCommand.GetBoolean("updated", &selectionUpdated)) |
+ selectionUpdated = false; |
+ bool selectionValid; |
+ if (!JSONCommand.GetBoolean("valid", &selectionValid)) |
+ selectionValid = true; |
+ base::WeakNSObject<ContextualSearchController> weakSelf(self); |
+ ProceduralBlockWithBool lockAction = ^(BOOL lockAcquired) { |
+ if (lockAcquired) { |
+ [weakSelf handleSelectionChanged:selectedText |
+ selectionUpdated:selectionUpdated |
+ selectionValid:selectionValid]; |
+ } |
+ }; |
+ DOMAlteringLock::FromWebState([self webState])->Acquire(self, lockAction); |
+ return YES; |
+ } |
+ if (command == "contextualSearch.mutationEvent") { |
+ if ([_panelView state] <= ContextualSearch::PEEKING && |
+ !_searchTermResolved) { |
+ [self dismissPane:ContextualSearch::UNKNOWN]; |
+ } |
+ return YES; |
+ } |
+ return NO; |
+} |
+ |
+- (void)ignoreTap:(UIGestureRecognizer*)recognizer { |
+ // This method is intentionally empty. It is intended to ignore the tap. |
+} |
+ |
+- (void)handleTapFrom:(UIGestureRecognizer*)recognizer { |
+ DCHECK(recognizer == _tapRecognizer.get()); |
+ // Taps will be triggered by long-presses to make a selection in the webview, |
+ // as well as 'regular' taps. Long-presses that create a selection will set |
+ // |_newSelectionDisplaying| as well as populating _selectedText (this happens |
+ // in -handleScriptCommand:). |
+ |
+ // If we just dismissed, do not consider this tap. |
+ NSTimeInterval dismissTimeout = [_lastDismiss timeIntervalSinceNow] + |
+ kPreventTriggerAfterDismissDelaySeconds; |
+ |
+ // If the panel is already displayed, just dismiss it and return, unless the |
+ // tap was from displaying a new selection. |
+ if (([_panelView state] != ContextualSearch::DISMISSED && |
+ !_newSelectionDisplaying) || |
+ dismissTimeout > 0) { |
+ [self dismissPane:ContextualSearch::BASE_PAGE_TAP]; |
+ return; |
+ } |
+ // Otherwise handle the tap. |
+ [_tapRecognizer setEnabled:NO]; |
+ _currentTapCancelled = NO; |
+ _newSelectionDisplaying = NO; |
+ ProceduralBlockWithBool lockAction = ^(BOOL lockAcquired) { |
+ if (!lockAcquired || !_isScriptInjected || _currentTapCancelled || |
+ [recognizer state] != UIGestureRecognizerStateEnded || |
+ !_selectedText.empty()) { |
+ [_tapRecognizer setEnabled:YES]; |
+ if (!_selectedText.empty()) |
+ _webViewTappedWithSelection = YES; |
+ return; |
+ } |
+ |
+ CGPoint tapPoint = [recognizer locationInView:recognizer.view]; |
+ // tapPoint is the coordinate of the tap in the webView. If the view is |
+ // currently offset because a header is displayed, offset the tapPoint. |
+ tapPoint.y -= [_controllerDelegate currentHeaderHeight]; |
+ |
+ // Handle tap asynchronously to monitor DOM modifications. See comment |
+ // of |kDOMModificationDelaySeconds| for details. |
+ dispatch_time_t dispatch = dispatch_time( |
+ DISPATCH_TIME_NOW, |
+ static_cast<int64_t>(kDOMModificationDelaySeconds * NSEC_PER_SEC)); |
+ base::WeakNSObject<ContextualSearchController> weakSelf(self); |
+ dispatch_after(dispatch, dispatch_get_main_queue(), ^{ |
+ [weakSelf handleTapAtPoint:tapPoint]; |
+ }); |
+ }; |
+ DOMAlteringLock::FromWebState([self webState])->Acquire(self, lockAction); |
+} |
+ |
+- (void)handleLongPressFrom:(UIGestureRecognizer*)recognizer { |
+ DCHECK(recognizer == _copyGestureRecognizer.get()); |
+ if (recognizer.state != UIGestureRecognizerStateEnded) |
+ return; |
+ |
+ // Put the resolved search term (or the current selected text) into the |
+ // pasteboard. |
+ std::string text; |
+ if (!_resolvedSearch.display_text.empty()) { |
+ text = _resolvedSearch.display_text; |
+ } |
+ |
+ if (!text.empty()) { |
+ UIPasteboard* pasteboard = [UIPasteboard generalPasteboard]; |
+ pasteboard.string = base::SysUTF8ToNSString(_resolvedSearch.display_text); |
+ // Let the user know. |
+ NSString* messageText = l10n_util::GetNSString(IDS_IOS_SEARCH_COPIED); |
+ MDCSnackbarMessage* message = |
+ [MDCSnackbarMessage messageWithText:messageText]; |
+ message.duration = 1.0; |
+ message.category = @"search term copied"; |
+ [MDCSnackbarManager showMessage:message]; |
+ } |
+} |
+ |
+- (void)handleTapAtPoint:(CGPoint)point { |
+ _tapTime = base::Time::Now(); |
+ if (_currentTapCancelled) { |
+ [_tapRecognizer setEnabled:YES]; |
+ return; |
+ } |
+ |
+ _searchTriggeredBySelection = NO; |
+ |
+ // TODO(crbug.com/546220): Detect and use actual page encoding. |
+ std::string encoding = "UTF-8"; |
+ |
+ CGPoint relativeTapPoint = point; |
+ CGSize contentSize = [_webViewProxy scrollViewProxy].contentSize; |
+ relativeTapPoint.x += [_webViewProxy scrollViewProxy].contentOffset.x; |
+ relativeTapPoint.y += [_webViewProxy scrollViewProxy].contentOffset.y; |
+ |
+ relativeTapPoint.x /= contentSize.width; |
+ relativeTapPoint.y /= contentSize.height; |
+ |
+ base::WeakNSProtocol<id<CRWWebViewProxy>> weakWebViewProxy( |
+ _webViewProxy.get()); |
+ void (^handler)(NSString*) = ^(NSString* result) { |
+ [_tapRecognizer setEnabled:YES]; |
+ // If there has been an error in the javascript, return can be nil. |
+ if (!result || _currentTapCancelled) |
+ return; |
+ |
+ // Parse JSON. |
+ const std::string json = base::SysNSStringToUTF8(result); |
+ std::unique_ptr<base::Value> parsedResult( |
+ base::JSONReader::Read(json, false)); |
+ if (!parsedResult.get() || |
+ !parsedResult->IsType(base::Value::Type::DICTIONARY)) { |
+ return; |
+ } |
+ |
+ base::DictionaryValue* resultDict = |
+ static_cast<base::DictionaryValue*>(parsedResult.get()); |
+ const base::DictionaryValue* context = nullptr; |
+ BOOL contextError = NO; |
+ if (!resultDict->GetDictionary("context", &context)) { |
+ // No context returned -- the tap wasn't on a word. |
+ DVLOG(1) << "Contextual search results did not include a context."; |
+ contextError = YES; |
+ } else { |
+ std::string error; |
+ context->GetString("error", &error); |
+ if (!error.empty()) { |
+ // Something went wrong! |
+ DVLOG(0) << "Contextual search error: " << error; |
+ contextError = YES; |
+ } |
+ } |
+ |
+ if (contextError) { |
+ _searchContext.reset(); |
+ [self updateUI]; |
+ // The JavaScript will have taken care of clearing the highlighting. |
+ return; |
+ } |
+ |
+ // Marshall the retrieved context. |
+ std::string url, selectedText; |
+ BOOL marshallingOK = YES; |
+ GURL sentUrl; |
+ if ([self.permissions canSendPageURLs]) { |
+ marshallingOK = marshallingOK && context->GetString("url", &url); |
+ sentUrl = GURL(url); |
+ } |
+ marshallingOK = |
+ marshallingOK && context->GetString("selectedText", &selectedText); |
+ |
+ if (!marshallingOK) { |
+ _searchContext.reset(); |
+ [self updateUI]; |
+ // The JavaScript will have taken care of clearing the highlighting. |
+ return; |
+ } |
+ _searchContext.reset( |
+ new ContextualSearchContext(selectedText, true, sentUrl, encoding)); |
+ |
+ if ([self canExtractTapContext]) { |
+ marshallingOK = |
+ marshallingOK && |
+ context->GetString("surroundingText", |
+ &_searchContext->surrounding_text) && |
+ context->GetInteger("offsetStart", &_searchContext->start_offset) && |
+ context->GetInteger("offsetEnd", &_searchContext->end_offset); |
+ } |
+ |
+ if (!marshallingOK) { |
+ _searchContext.reset(); |
+ [self updateUI]; |
+ // The JavaScript will have taken care of clearing the highlighting. |
+ return; |
+ } |
+ |
+ DVLOG(1) << "Contextual search results:\n" |
+ << " URL: " << _searchContext->page_url.spec() << "\n" |
+ << " selectedText: " << _searchContext->selected_text << "\n" |
+ << " offsets: " << _searchContext->start_offset << "-" |
+ << _searchContext->end_offset << "\n" |
+ << " surroundingText: " << _searchContext->surrounding_text; |
+ |
+ std::string rects; |
+ if (!context->GetString("rects", &rects)) { |
+ _searchContext.reset(); |
+ [self updateUI]; |
+ return; |
+ } |
+ NSArray* rectsArray = StringValueToRectArray(rects); |
+ if (!rectsArray) { |
+ _searchContext.reset(); |
+ [self updateUI]; |
+ return; |
+ } |
+ [self highlightRects:rectsArray]; |
+ |
+ [self scrollToShowSelection:[weakWebViewProxy scrollViewProxy]]; |
+ |
+ // Update the content view and the state of the UI. |
+ [self updateUI]; |
+ _preventPreload = NO; |
+ |
+ _delegate->PostSearchTermRequest(_searchContext); |
+ _searchTriggeredBySelection = NO; |
+ }; |
+ [_contextualSearchJsManager fetchContextFromSelectionAtPoint:relativeTapPoint |
+ completionHandler:handler]; |
+} |
+ |
+- (void)handleHighlightJSResult:(id)result withError:(NSError*)error { |
+ if (error) { |
+ [self highlightRects:nil]; |
+ [_contextualSearchJsManager clearHighlight]; |
+ return; |
+ } |
+ std::string JSON( |
+ base::SysNSStringToUTF8(base::mac::ObjCCastStrict<NSString>(result))); |
+ // |json| is a JSON dicionary containing at list 2 entries: |
+ // - 'rects': containing a list of rect dictionaries representing the zone of |
+ // the page to highlight as a string in the format |
+ // top1 bottom1 left1 right1,top2 bottom2 left2 right2,..., |
+ // - 'size': containing a dictionary containing the size of the document as |
+ // seen in JavaScript. |
+ // As the 'rects' coordinates are based on a document which size is contained |
+ // in 'size', if the web content view does not have the same size, they should |
+ // not be considered. |
+ |
+ std::unique_ptr<base::Value> parsedResult( |
+ base::JSONReader::Read(JSON, false)); |
+ if (!parsedResult.get() || |
+ !parsedResult->IsType(base::Value::Type::DICTIONARY)) { |
+ return; |
+ } |
+ base::DictionaryValue* resultDict = |
+ static_cast<base::DictionaryValue*>(parsedResult.get()); |
+ |
+ CGSize contentSize = [_webViewProxy scrollViewProxy].contentSize; |
+ const base::DictionaryValue* contentSizeDict; |
+ if (resultDict->GetDictionary("size", &contentSizeDict)) { |
+ double width, height; |
+ if (!contentSizeDict->GetDouble("height", &height) || |
+ !contentSizeDict->GetDouble("width", &width)) { |
+ // Value is not correctly formatted. Early return. |
+ return; |
+ } |
+ width *= [_webViewProxy scrollViewProxy].zoomScale; |
+ height *= [_webViewProxy scrollViewProxy].zoomScale; |
+ if (fabsl(contentSize.width - width) > 2 || |
+ fabsl(contentSize.height - height) > 2) { |
+ // The coords in of the UIView and in JavaScript are not synced. A scroll |
+ // now would be almost random. |
+ _preventScrollToShowSelection = YES; |
+ dispatch_after( |
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), |
+ dispatch_get_main_queue(), ^{ |
+ [self updateHighlight]; |
+ }); |
+ return; |
+ } |
+ _preventScrollToShowSelection = NO; |
+ } |
+ |
+ std::string rectsList; |
+ if (resultDict->GetString("rects", &rectsList)) { |
+ NSArray* rects = StringValueToRectArray(rectsList); |
+ if (rects) { |
+ [self highlightRects:rects]; |
+ [self scrollToShowSelection:[_webViewProxy scrollViewProxy]]; |
+ } |
+ } |
+} |
+ |
+- (void)updateForResolvedSearch: |
+ (ContextualSearchDelegate::SearchResolution)resolution { |
+ _resolvedSearch = resolution; |
+ |
+ DVLOG(1) << "is invalid: " << _resolvedSearch.is_invalid << "\n" |
+ << "response code: " << _resolvedSearch.response_code << "\n" |
+ << "search term: " << _resolvedSearch.search_term << "\n" |
+ << "search term: " << _resolvedSearch.alternate_term << "\n" |
+ << "display text: " << _resolvedSearch.display_text << "\n" |
+ << "stop preload: " << _resolvedSearch.prevent_preload; |
+ |
+ if (_resolvedSearch.is_invalid) { |
+ [self dismissPane:ContextualSearch::UNKNOWN]; |
+ } else { |
+ _searchTermResolved = YES; |
+ [_headerView |
+ setSearchTerm:base::SysUTF8ToNSString(_resolvedSearch.display_text) |
+ animated:[_panelView state] != ContextualSearch::DISMISSED]; |
+ if (_resolvedSearch.start_offset != -1 && |
+ _resolvedSearch.end_offset != -1) { |
+ base::WeakNSObject<ContextualSearchController> weakSelf(self); |
+ [_contextualSearchJsManager |
+ expandHighlightToStartOffset:_resolvedSearch.start_offset |
+ endOffset:_resolvedSearch.end_offset |
+ completionHandler:^(id result, NSError* error) { |
+ [weakSelf handleHighlightJSResult:result |
+ withError:error]; |
+ }]; |
+ } |
+ GURL url = _delegate->GetURLForResolvedSearch(_resolvedSearch, true); |
+ [_searchResultsView createTabForSearch:url |
+ preloadEnabled:!_resolvedSearch.prevent_preload]; |
+ // Record the tap-to-search interval. |
+ ContextualSearch::RecordTimeToSearch(base::Time::Now() - _tapTime); |
+ } |
+} |
+ |
+- (void)updateUI { |
+ if (_searchContext) { |
+ ContextualSearch::RecordSelectionIsValid(true); |
+ [self peekPane:ContextualSearch::TEXT_SELECT_TAP]; |
+ _searchInvolvedFirstRun = |
+ self.permissions.preferenceState == TouchToSearch::UNDECIDED; |
+ |
+ if (_searchContext->surrounding_text.empty()) { |
+ [_headerView |
+ setSearchTerm:base::SysUTF8ToNSString(_searchContext->selected_text) |
+ animated:[_panelView state] != ContextualSearch::DISMISSED]; |
+ } else { |
+ NSString* surroundingText = |
+ base::SysUTF16ToNSString(_searchContext->surrounding_text); |
+ NSInteger startOffset = |
+ CLAMP(0, _searchContext->start_offset, |
+ static_cast<NSInteger>([surroundingText length])); |
+ NSString* displayedText = |
+ [surroundingText substringFromIndex:startOffset]; |
+ NSInteger adjusted_offset = |
+ _searchContext->end_offset - _searchContext->start_offset; |
+ NSInteger followingOffset = CLAMP( |
+ 0, adjusted_offset, static_cast<NSInteger>([surroundingText length])); |
+ NSRange followingTextRange = |
+ NSMakeRange(followingOffset, displayedText.length - followingOffset); |
+ [_headerView setText:displayedText |
+ followingTextRange:followingTextRange |
+ animated:[_panelView state] != ContextualSearch::DISMISSED]; |
+ } |
+ } else { |
+ ContextualSearch::RecordSelectionIsValid(false); |
+ [self dismissPane:ContextualSearch::INVALID_SELECTION]; |
+ } |
+} |
+- (void)scrollToShowSelection:(CRWWebViewScrollViewProxy*)scrollView { |
+ if (!scrollView || _preventScrollToShowSelection) |
+ return; |
+ if (!_contextualHighlightView.get()) { |
+ return; |
+ } |
+ CGRect highlightBoundingRect = [_contextualHighlightView boundingRect]; |
+ if (CGRectIsNull(highlightBoundingRect)) { |
+ return; |
+ } |
+ |
+ // Do the maths without the insets. |
+ CGPoint scrollPoint = [scrollView contentOffset]; |
+ scrollPoint.y += scrollView.contentInset.top; |
+ scrollPoint.x += scrollView.contentInset.left; |
+ |
+ // Coordinates of the bounding box to show. |
+ CGFloat top = CGRectGetMinY(highlightBoundingRect); |
+ CGFloat bottom = CGRectGetMaxY(highlightBoundingRect); |
+ CGFloat left = CGRectGetMinX(highlightBoundingRect); |
+ CGFloat right = CGRectGetMaxX(highlightBoundingRect); |
+ |
+ CGSize displaySize = [_contextualHighlightView frame].size; |
+ |
+ CGFloat panelHeight = CGRectGetHeight( |
+ CGRectIntersection([_panelView frame], [_panelView superview].bounds)); |
+ |
+ displaySize.height -= scrollView.contentInset.top + |
+ scrollView.contentInset.bottom + panelHeight; |
+ displaySize.width -= |
+ scrollView.contentInset.left + scrollView.contentInset.right; |
+ |
+ // Coordinates of the displayed frame in the same coordinates system. |
+ CGFloat frameTop = scrollPoint.y; |
+ CGFloat frameBottom = frameTop + displaySize.height; |
+ CGFloat frameLeft = scrollPoint.x; |
+ CGFloat frameRight = frameLeft + displaySize.width; |
+ |
+ CGSize contentSize = scrollView.contentSize; |
+ CGFloat maxOffsetY = MAX(contentSize.height - displaySize.height, 0); |
+ CGFloat maxOffsetX = MAX(contentSize.width - displaySize.width, 0); |
+ |
+ if (highlightBoundingRect.size.width + 2 * kXScrollMargin > |
+ displaySize.width) { |
+ // Selection does not fit in the screen. Center horizontal scroll. |
+ if (contentSize.width > displaySize.width) { |
+ scrollPoint.x = (left + right - displaySize.width) / 2; |
+ } |
+ } else { |
+ // Make sure right is visible. |
+ if (right + kXScrollMargin > frameRight) { |
+ scrollPoint.x = right + kXScrollMargin - displaySize.width; |
+ } |
+ |
+ // Make sure left is visible. |
+ if (left - kXScrollMargin < frameLeft) { |
+ scrollPoint.x = left - kXScrollMargin; |
+ } |
+ } |
+ |
+ // Make sure bottom is visible. |
+ if (bottom + kYScrollMargin > frameBottom) { |
+ scrollPoint.y = bottom + kYScrollMargin - displaySize.height; |
+ } |
+ |
+ // Make sure top is visible. |
+ if (top - kYScrollMargin - [_controllerDelegate currentHeaderHeight] < |
+ frameTop) { |
+ scrollPoint.y = |
+ top - kYScrollMargin - [_controllerDelegate currentHeaderHeight]; |
+ } |
+ |
+ if (scrollPoint.x < 0) |
+ scrollPoint.x = 0; |
+ if (scrollPoint.x > maxOffsetX) { |
+ scrollPoint.x = maxOffsetX; |
+ } |
+ if (scrollPoint.y < 0) |
+ scrollPoint.y = 0; |
+ if (scrollPoint.y > maxOffsetY) |
+ scrollPoint.y = maxOffsetY; |
+ |
+ scrollPoint.y -= scrollView.contentInset.top; |
+ scrollPoint.x -= scrollView.contentInset.left; |
+ [scrollView setContentOffset:scrollPoint animated:YES]; |
+} |
+ |
+- (void)highlightRects:(NSArray*)rects { |
+ if (![self webState]) { |
+ return; |
+ } |
+ if (!_contextualHighlightView.get() && [rects count]) { |
+ CGRect frame = [[self webState]->GetWebViewProxy() frame]; |
+ ContextualSearchHighlighterView* highlightView = |
+ [[[ContextualSearchHighlighterView alloc] initWithFrame:frame |
+ delegate:self] |
+ autorelease]; |
+ _contextualHighlightView.reset(highlightView); |
+ [[self webState]->GetWebViewProxy() addSubview:highlightView]; |
+ } |
+ CGPoint scroll = [[_webViewProxy scrollViewProxy] contentOffset]; |
+ [_contextualHighlightView |
+ highlightRects:rects |
+ withOffset:[_controllerDelegate currentHeaderHeight] |
+ zoom:[[_webViewProxy scrollViewProxy] zoomScale] |
+ scroll:scroll]; |
+} |
+ |
+- (void)willShowMenuNotification { |
+ if (!_preventActionMenu) |
+ return; |
+ BOOL dismiss = NO; |
+ if ([_panelView state] > ContextualSearch::PEEKING) { |
+ dismiss = YES; |
+ } |
+ if ([_panelView state] == ContextualSearch::PEEKING) { |
+ CGPoint headerTop = [_headerView convertPoint:CGPointZero toView:nil]; |
+ CGRect menuRect = [[UIMenuController sharedMenuController] menuFrame]; |
+ if (headerTop.y < CGRectGetMaxY(menuRect)) { |
+ dismiss = YES; |
+ } |
+ } |
+ if (dismiss) { |
+ dispatch_async(dispatch_get_main_queue(), ^{ |
+ [[UIMenuController sharedMenuController] setMenuVisible:NO]; |
+ }); |
+ } |
+} |
+ |
+- (void)close { |
+ if (_closed) |
+ return; |
+ |
+ _closed = YES; |
+ [self disableCurrentWebState]; |
+ [self setWebState:nil]; |
+ [_headerView removeGestureRecognizer:_copyGestureRecognizer]; |
+ [[_panelView window] removeGestureRecognizer:_dismissRecognizer]; |
+ _delegate.reset(); |
+ [_searchResultsView setActive:NO]; |
+ _searchResultsView.reset(); |
+} |
+ |
+#pragma mark - Promo view management |
+ |
+- (void)userOptedInFromPromo:(BOOL)optIn { |
+ if (optIn) { |
+ self.permissions.preferenceState = TouchToSearch::ENABLED; |
+ [_promoView closeAnimated:YES]; |
+ [_promoView setDisabled:YES]; |
+ } else { |
+ [self dismissPane:ContextualSearch::OPTOUT]; |
+ self.permissions.preferenceState = TouchToSearch::DISABLED; |
+ } |
+ ContextualSearch::RecordFirstRunFlowOutcome(self.permissions.preferenceState); |
+} |
+ |
+#pragma mark - ContextualSearchPreloadChecker |
+ |
+- (BOOL)canPreloadSearchResults { |
+ if (_preventPreload) { |
+ return NO; |
+ } |
+ return [self.permissions canPreloadSearchResults]; |
+} |
+ |
+#pragma mark - ContextualSearchTabPromoter |
+ |
+- (void)promoteTabHeaderPressed:(BOOL)headerPressed { |
+ // Move the panel so it's covering before the transition. |
+ if ([_panelView state] != ContextualSearch::COVERING) { |
+ [self coverPane:ContextualSearch::SERP_NAVIGATION]; |
+ } |
+ // TODO(crbug.com/455334): Make this transition look nicer. |
+ [_searchResultsView scrollToTopAnimated:YES]; |
+ |
+ [self cleanUpWebStateForDismissWithCompletion:nil]; |
+ |
+ // Tell the BVC to handle the promotion, which will cause a new panel view |
+ // to be created. |
+ [_controllerDelegate promotePanelToTabProvidedBy:_searchResultsView |
+ focusInput:NO]; |
+} |
+ |
+#pragma mark - ContextualSearchPanelMotionObserver |
+ |
+- (void)panel:(ContextualSearchPanelView*)panel |
+ didStopMovingWithMotion:(ContextualSearch::PanelMotion)motion { |
+ if (motion.state == ContextualSearch::DISMISSED) { |
+ [self dismissPane:ContextualSearch::SWIPE]; |
+ } else if (motion.state == ContextualSearch::PEEKING) { |
+ // newOrigin is above peeking height but below preview height. |
+ if ([_panelView state] >= ContextualSearch::PREVIEWING) { |
+ // Dragged down from previewing or covering |
+ [self peekPane:ContextualSearch::SWIPE]; |
+ } else { |
+ // Dragged up or stayed the same. |
+ [self previewPane:ContextualSearch::SWIPE]; |
+ } |
+ } else { |
+ if ([_panelView state] == ContextualSearch::COVERING) { |
+ if (motion.state != ContextualSearch::COVERING) { |
+ // Dragged down from covering. |
+ [self previewPane:ContextualSearch::SWIPE]; |
+ } |
+ } else { |
+ // Dragged up. |
+ [self coverPane:ContextualSearch::SWIPE]; |
+ } |
+ } |
+ [self updateHighlight]; |
+} |
+ |
+- (void)panelWillPromote:(ContextualSearchPanelView*)panel { |
+ DCHECK(panel == _panelView); |
+ [panel removeMotionObserver:self]; |
+ _panelView.reset(); |
+ [self setState:ContextualSearch::DISMISSED |
+ reason:ContextualSearch::TAB_PROMOTION]; |
+} |
+ |
+#pragma mark - ContextualSearchPanelTapHandler |
+ |
+- (void)panelWasTapped:(UIGestureRecognizer*)gesture { |
+ // Tapping when peeking switches to previewing. |
+ // Tapping otherwise turns the panel into a tab. |
+ if ([_panelView state] == ContextualSearch::PEEKING) { |
+ [self previewPane:ContextualSearch::SEARCH_BAR_TAP]; |
+ } else { |
+ [self promoteTabHeaderPressed:YES]; |
+ } |
+} |
+ |
+- (void)closePanel { |
+ [self dismissPane:ContextualSearch::SEARCH_BAR_TAP]; |
+} |
+ |
+#pragma mark - State change methods |
+ |
+- (void)setState:(ContextualSearch::PanelState)state |
+ reason:(ContextualSearch::StateChangeReason)reason { |
+ ContextualSearch::PanelState fromState = [_panelView state]; |
+ |
+ // If we're moving to PEEKING as a result of text selection, that's starting |
+ // a new search. |
+ BOOL startingSearch = state == ContextualSearch::PEEKING && |
+ (reason == ContextualSearch::TEXT_SELECT_TAP || |
+ reason == ContextualSearch::TEXT_SELECT_LONG_PRESS); |
+ // If we're showing anything, then there's an ongoing search. |
+ BOOL ongoingSearch = fromState > ContextualSearch::DISMISSED; |
+ // If there's an ongoing search and we're dismissing or starting a search, |
+ // then we're ending a search. |
+ BOOL endingSearch = |
+ ongoingSearch && (state == ContextualSearch::DISMISSED || startingSearch); |
+ // If we're starting a search while there's one already there, it's chained. |
+ BOOL chained = startingSearch && endingSearch; |
+ |
+ BOOL sameState = fromState == state; |
+ BOOL firstExitFromPeeking = fromState == ContextualSearch::PEEKING && |
+ !_exitedPeeking && (!sameState || startingSearch); |
+ BOOL firstExitFromPreviewing = fromState == ContextualSearch::PREVIEWING && |
+ !_exitedPreviewing && !sameState; |
+ BOOL firstExitFromCovering = |
+ fromState == ContextualSearch::COVERING && !_exitedCovering && !sameState; |
+ |
+ _resultsVisible = _resultsVisible || [_searchResultsView contentVisible]; |
+ |
+ if (endingSearch) { |
+ if (_searchInvolvedFirstRun) { |
+ // If the first run panel might have been shown, did the user see it? |
+ ContextualSearch::RecordFirstRunPanelSeen(_firstRunPanelBecameVisible); |
+ } |
+ // Record search timing. |
+ [_searchResultsView recordFinishedSearchChained:chained]; |
+ // Record if the user saw the search results. |
+ if (_searchTriggeredBySelection) { |
+ ContextualSearch::RecordSelectionResultsSeen(_resultsVisible); |
+ } else { |
+ ContextualSearch::RecordTapResultsSeen(_resultsVisible); |
+ } |
+ } |
+ |
+ // Log state change. We only log the first transition to a state within a |
+ // contextual search. Note that when a user clicks on a link on the search |
+ // content view, this will trigger a transition to COVERING (SERP_NAVIGATION) |
+ // followed by a transition to DISMISSED (TAB_PROMOTION). For the purpose of |
+ // logging, the reason for the second transition is reinterpreted to |
+ // SERP_NAVIGATION, in order to distinguish it from a tab promotion caused |
+ // when tapping on the header when the panel is maximized. |
+ ContextualSearch::StateChangeReason loggedReason = |
+ _usedSERPNavigation ? ContextualSearch::SERP_NAVIGATION : reason; |
+ if (startingSearch || endingSearch || |
+ (!sameState && !_enteredPreviewing && |
+ state == ContextualSearch::PREVIEWING) || |
+ (!sameState && !_enteredCovering && |
+ state == ContextualSearch::COVERING)) { |
+ ContextualSearch::RecordFirstStateEntry(fromState, state, loggedReason); |
+ } |
+ if ((startingSearch && !chained) || firstExitFromPeeking || |
+ firstExitFromPreviewing || firstExitFromCovering) { |
+ ContextualSearch::RecordFirstStateExit(fromState, state, loggedReason); |
+ } |
+ |
+ if (firstExitFromPeeking) { |
+ _exitedPeeking = YES; |
+ } else if (firstExitFromPreviewing) { |
+ _exitedPreviewing = YES; |
+ } else if (firstExitFromCovering) { |
+ _exitedCovering = YES; |
+ } |
+ |
+ [_panelView setState:state]; |
+ // If the panel is now visible, enable the window-tap detector. |
+ |
+ [self updateDismissRecognizer]; |
+ |
+ if (state == ContextualSearch::PREVIEWING) { |
+ _enteredPreviewing = YES; |
+ } else if (state == ContextualSearch::COVERING) { |
+ _enteredCovering = YES; |
+ } |
+ |
+ if (reason == ContextualSearch::SERP_NAVIGATION) { |
+ _usedSERPNavigation = YES; |
+ } |
+ |
+ if (endingSearch) { |
+ _enteredPreviewing = NO; |
+ _enteredCovering = NO; |
+ _resultsVisible = NO; |
+ _exitedPeeking = NO; |
+ _exitedPreviewing = NO; |
+ _exitedCovering = NO; |
+ _searchInvolvedFirstRun = NO; |
+ _firstRunPanelBecameVisible = NO; |
+ _searchTermResolved = NO; |
+ _usedSERPNavigation = NO; |
+ } |
+} |
+ |
+- (void) |
+dismissPaneWithJavascriptCompletionHandler:(ProceduralBlock)completionHandler |
+ reason:(ContextualSearch::StateChangeReason) |
+ reason { |
+ [self cleanUpWebStateForDismissWithCompletion:completionHandler]; |
+ [self setState:ContextualSearch::DISMISSED reason:reason]; |
+} |
+ |
+- (void)cleanUpWebStateForDismissWithCompletion: |
+ (ProceduralBlock)completionHandler { |
+ _lastDismiss.reset([[NSDate date] retain]); |
+ _currentTapCancelled = YES; |
+ ContextualSearch::PanelState originalState = [_panelView state]; |
+ if (originalState == ContextualSearch::DISMISSED) { |
+ DCHECK(![_searchResultsView active]); |
+ if ([self webState]) { |
+ DOMAlteringLock* lock = DOMAlteringLock::FromWebState([self webState]); |
+ if (lock) { |
+ lock->Release(self); |
+ } |
+ } |
+ if (completionHandler) |
+ completionHandler(); |
+ return; |
+ } |
+ |
+ [_doubleTapRecognizer setEnabled:YES]; |
+ _searchContext.reset(); |
+ [_searchResultsView setActive:NO]; |
+ _delegate->CancelSearchTermRequest(); |
+ _selectedText = ""; |
+ |
+ ContextualSearchDelegate::SearchResolution blank; |
+ _resolvedSearch = blank; |
+ if (completionHandler) { |
+ base::WeakNSObject<ContextualSearchController> weakSelf(self); |
+ ProceduralBlock javaScriptCompletion = ^{ |
+ if ([self webState]) { |
+ DOMAlteringLock::FromWebState([self webState])->Release(self); |
+ completionHandler(); |
+ } |
+ }; |
+ [self highlightRects:nil]; |
+ [_contextualSearchJsManager clearHighlight]; |
+ javaScriptCompletion(); |
+ } else { |
+ [self highlightRects:nil]; |
+ [_contextualSearchJsManager clearHighlight]; |
+ DOMAlteringLock::FromWebState([self webState])->Release(self); |
+ } |
+ |
+ _preventActionMenu = NO; |
+ |
+ // If the tapped word was at the bottom of the webview, and it was scrolled |
+ // up to be displayed over the pane, scroll it back down now. |
+ // (Ideally this "overscrolling" should just happen as the pane moves). |
+ // TODO(crbug.com/546227): Handle this with a constraint. |
+ CGPoint contentOffset = [[_webViewProxy scrollViewProxy] contentOffset]; |
+ CGSize contentSize = [[_webViewProxy scrollViewProxy] contentSize]; |
+ CGSize viewSize = [[_webViewProxy scrollViewProxy] frame].size; |
+ CGFloat maxOffset = contentSize.height - viewSize.height; |
+ if (contentOffset.y > maxOffset) { |
+ contentOffset.y = maxOffset; |
+ [[_webViewProxy scrollViewProxy] setContentOffset:contentOffset |
+ animated:YES]; |
+ } |
+} |
+ |
+- (void)dismissPane:(ContextualSearch::StateChangeReason)reason { |
+ [self dismissPaneWithJavascriptCompletionHandler:nil reason:reason]; |
+} |
+ |
+- (void)peekPane:(ContextualSearch::StateChangeReason)reason { |
+ [self setState:ContextualSearch::PEEKING reason:reason]; |
+ [_doubleTapRecognizer setEnabled:NO]; |
+ [self scrollToShowSelection:[_webViewProxy scrollViewProxy]]; |
+} |
+ |
+- (void)previewPane:(ContextualSearch::StateChangeReason)reason { |
+ if (_searchInvolvedFirstRun) { |
+ _firstRunPanelBecameVisible = YES; |
+ } |
+ [self setState:ContextualSearch::PREVIEWING reason:reason]; |
+ [_doubleTapRecognizer setEnabled:NO]; |
+ [self scrollToShowSelection:[_webViewProxy scrollViewProxy]]; |
+ _delegate->StartPendingSearchTermRequest(); |
+} |
+ |
+- (void)coverPane:(ContextualSearch::StateChangeReason)reason { |
+ [self setState:ContextualSearch::COVERING reason:reason]; |
+} |
+ |
+- (void)movePanelOffscreen { |
+ [self dismissPane:ContextualSearch::RESET]; |
+} |
+ |
+#pragma mark - ContextualSearchPromoViewDelegate methods |
+ |
+- (void)promoViewAcceptTapped { |
+ [self userOptedInFromPromo:YES]; |
+} |
+ |
+- (void)promoViewDeclineTapped { |
+ [self userOptedInFromPromo:NO]; |
+} |
+ |
+- (void)promoViewSettingsTapped { |
+ base::scoped_nsobject<GenericChromeCommand> command( |
+ [[GenericChromeCommand alloc] |
+ initWithTag:IDC_SHOW_CONTEXTUAL_SEARCH_SETTINGS]); |
+ UIWindow* main_window = [[UIApplication sharedApplication] keyWindow]; |
+ [main_window chromeExecuteCommand:command]; |
+} |
+ |
+#pragma mark - ContextualSearchWebStateObserver methods |
+ |
+- (void)webState:(web::WebState*)webState |
+ pageLoadedWithStatus:(web::PageLoadCompletionStatus)loadStatus { |
+ if (loadStatus != web::PageLoadCompletionStatus::SUCCESS) |
+ return; |
+ |
+ [self movePanelOffscreen]; |
+ _isScriptInjected = NO; |
+ [self enableCurrentWebState]; |
+} |
+ |
+- (void)webStateDestroyed:(web::WebState*)webState { |
+ [self updateWebViewProxy:nil]; |
+} |
+ |
+#pragma mark - UIGestureRecognizerDelegate Methods |
+ |
+// Ensures that |_tapRecognizer| and |_doubleTapRecognizer| cooperate with all |
+// other gesture recognizers. |
+- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer |
+ shouldRecognizeSimultaneouslyWithGestureRecognizer: |
+ (UIGestureRecognizer*)otherGestureRecognizer { |
+ return gestureRecognizer == _tapRecognizer.get() || |
+ gestureRecognizer == _doubleTapRecognizer.get(); |
+} |
+ |
+#pragma mark - CRWWebViewScrollViewObserver methods |
+ |
+- (void)webViewScrollViewWillBeginDragging: |
+ (CRWWebViewScrollViewProxy*)webViewScrollViewProxy { |
+ [self dismissPane:ContextualSearch::BASE_PAGE_SCROLL]; |
+ [_tapRecognizer setEnabled:NO]; |
+} |
+ |
+- (void)webViewScrollViewDidEndDragging: |
+ (CRWWebViewScrollViewProxy*)webViewScrollViewProxy |
+ willDecelerate:(BOOL)decelerate { |
+ if (!decelerate) |
+ [_tapRecognizer setEnabled:YES]; |
+} |
+ |
+- (void)webViewScrollViewDidEndDecelerating: |
+ (CRWWebViewScrollViewProxy*)webViewScrollViewProxy { |
+ [_tapRecognizer setEnabled:YES]; |
+} |
+ |
+- (void)webViewScrollViewDidScroll: |
+ (CRWWebViewScrollViewProxy*)webViewScrollViewProxy { |
+ _currentTapCancelled = YES; |
+ [_contextualHighlightView |
+ setScroll:[webViewScrollViewProxy contentOffset] |
+ zoom:[webViewScrollViewProxy zoomScale] |
+ offset:[_controllerDelegate currentHeaderHeight]]; |
+} |
+ |
+- (void)webViewScrollViewDidZoom: |
+ (CRWWebViewScrollViewProxy*)webViewScrollViewProxy { |
+ _currentTapCancelled = YES; |
+ [_contextualHighlightView |
+ setScroll:[webViewScrollViewProxy contentOffset] |
+ zoom:[webViewScrollViewProxy zoomScale] |
+ offset:[_controllerDelegate currentHeaderHeight]]; |
+ [self scrollToShowSelection:webViewScrollViewProxy]; |
+} |
+ |
+#pragma mark - DOMAltering methods |
+ |
+- (BOOL)canReleaseDOMLock { |
+ return YES; |
+} |
+ |
+- (void)releaseDOMLockWithCompletionHandler:(ProceduralBlock)completionHandler { |
+ [self dismissPaneWithJavascriptCompletionHandler:completionHandler |
+ reason:ContextualSearch::RESET]; |
+} |
+ |
+#pragma mark - TouchToSearchPermissionsChangeAudience methods |
+ |
+- (void)touchToSearchDidChangePreferenceState: |
+ (TouchToSearch::TouchToSearchPreferenceState)preferenceState { |
+ if (preferenceState != TouchToSearch::UNDECIDED) { |
+ ContextualSearch::RecordPreferenceChanged(preferenceState == |
+ TouchToSearch::ENABLED); |
+ } |
+} |
+ |
+- (void)touchToSearchPermissionsUpdated { |
+ // This method is already invoked asynchronously, so it's safe to |
+ // synchronously attempt to enable the feature. |
+ [self enableContextualSearch:YES]; |
+} |
+ |
+#pragma mark - ContextualSearchHighlighterDelegate methods |
+ |
+- (void)updateHighlight { |
+ base::WeakNSObject<ContextualSearchController> weakSelf(self); |
+ [_contextualSearchJsManager |
+ highlightRectsWithCompletionHandler:^void(id result, NSError* error) { |
+ [weakSelf handleHighlightJSResult:result withError:error]; |
+ }]; |
+} |
+ |
+@end |