Chromium Code Reviews| Index: ios/web/web_state/ui/crw_context_menu_controller.mm |
| diff --git a/ios/web/web_state/ui/crw_context_menu_controller.mm b/ios/web/web_state/ui/crw_context_menu_controller.mm |
| new file mode 100644 |
| index 0000000000000000000000000000000000000000..f6d7f18e64a74d39871e4ec46ee331f73bda0a49 |
| --- /dev/null |
| +++ b/ios/web/web_state/ui/crw_context_menu_controller.mm |
| @@ -0,0 +1,336 @@ |
| +// Copyright 2017 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 <objc/runtime.h> |
| +#include <stddef.h> |
|
jif
2017/01/12 17:13:12
This group of #imports/#includes should be after t
Olivier
2017/01/13 10:20:26
Done.
|
| + |
| +#import "ios/web/public/web_state/ui/crw_context_menu_controller.h" |
| + |
| +#import "base/ios/weak_nsobject.h" |
| +#include "base/mac/foundation_util.h" |
| +#include "base/metrics/histogram.h" |
| +#include "base/strings/sys_string_conversions.h" |
| +#include "components/url_formatter/url_formatter.h" |
| +#include "ios/web/public/referrer_util.h" |
| + |
| +namespace { |
| +// The long press detection duration must be shorter than the UIWebView's |
|
Eugene But (OOO till 7-30)
2017/01/13 07:16:27
s/UIWebView/WKWebView
Olivier
2017/01/13 10:20:25
Done.
|
| +// long click gesture recognizer's minimum duration. That is 0.55s. |
| +// If our detection duration is shorter, our gesture recognizer will fire |
| +// first, and if it fails the long click gesture (processed simultaneously) |
| +// still is able to complete. |
| +const NSTimeInterval kLongPressDurationSeconds = 0.55 - 0.1; |
| + |
| +// If there is a movement bigger than |kLongPressMoveDeltaPixels|, the context |
| +// menu will not be triggered. |
| +const CGFloat kLongPressMoveDeltaPixels = 10.0; |
| + |
| +// Cancels touch events for the given gesture recognizer. |
| +void CancelTouches(UIGestureRecognizer* gesture_recognizer) { |
| + if (gesture_recognizer.enabled) { |
| + gesture_recognizer.enabled = NO; |
| + gesture_recognizer.enabled = YES; |
| + } |
| +} |
| +} |
|
jif
2017/01/12 17:13:12
} // namespace
Olivier
2017/01/13 10:20:25
Done.
|
| + |
| +@interface CRWContextMenuController ()<UIGestureRecognizerDelegate> |
| + |
| +// The scroll view of |webView|. |
| +@property(nonatomic, readonly) UIScrollView* webScrollView; |
| +// The |webView|. |
| +@property(nonatomic, readonly) id<CRWContextMenuControllerWebView> webView; |
| +// The scroll view of |webView|. |
| +@property(nonatomic, readonly) id<CRWContextMenuControllerDelegate> delegate; |
| +// Returns the x, y offset the content has been scrolled. |
| +@property(nonatomic, readonly) CGPoint scrollPosition; |
| + |
| +// Called when the window has determined there was a long-press and context menu |
| +// must be shown. |
| +- (void)showContextMenu:(UIGestureRecognizer*)gestureRecognizer; |
| +// Extracts context menu information from the given DOM element. |
| +- (web::ContextMenuParams)contextMenuParamsForElement:(NSDictionary*)element; |
| +// Cancels all touch events in the web view (long presses, tapping, scrolling). |
| +- (void)cancelAllTouches; |
| +// Asynchronously fetches full width of the rendered web page. |
| +- (void)fetchWebPageWidthWithCompletionHandler:(void (^)(CGFloat))handler; |
| +// Asynchronously fetches information about DOM element for the given point (in |
| +// UIView coordinates). |handler| can not be nil. See |_DOMElementForLastTouch| |
| +// for element format description. |
| +- (void)fetchDOMElementAtPoint:(CGPoint)point |
| + completionHandler:(void (^)(NSDictionary*))handler; |
| +// Sets the value of |_DOMElementForLastTouch|. |
| +- (void)setDOMElementForLastTouch:(NSDictionary*)element; |
| +@end |
| + |
| +@implementation CRWContextMenuController { |
| + id<CRWContextMenuControllerDelegate> _delegate; |
| + id<CRWContextMenuControllerWebView> _webView; |
| + |
| + // Long press recognizer that allows showing context menus. |
| + base::scoped_nsobject<UILongPressGestureRecognizer> _contextMenuRecognizer; |
| + // DOM element information for the point where the user made the last touch. |
| + // Can be nil if has not been calculated yet. Precalculation is necessary |
| + // because retreiving DOM element relies on async API so element info can not |
| + // be built on demand. May contain the following keys: @"href", @"src", |
| + // @"title", @"referrerPolicy". All values are strings. Used for showing |
| + // context menus. |
| + base::scoped_nsobject<NSDictionary> _DOMElementForLastTouch; |
| +} |
| + |
| +@synthesize delegate = _delegate; |
| +@synthesize webView = _webView; |
| + |
| +- (instancetype)initWithWebView:(id<CRWContextMenuControllerWebView>)webView |
| + delegate:(id<CRWContextMenuControllerDelegate>)delegate { |
| + self = [super init]; |
| + if (self) { |
| + _webView = webView; |
| + _delegate = delegate; |
| + |
| + // On iOS 4.x, there are two gesture recognizers on the UIWebView |
|
Eugene But (OOO till 7-30)
2017/01/13 07:16:27
Could you please update this comment.
Olivier
2017/01/13 10:20:25
Done.
|
| + // subclasses, |
| + // that have a minimum tap threshold of 0.12s and 0.75s. |
| + // |
| + // My theory is that the shorter threshold recognizer performs the link |
| + // highlight (grey highlight around links when it is tapped and held) while |
| + // the longer threshold one pops up the context menu. |
| + // |
| + // To override the context menu, this recognizer needs to react faster than |
| + // the 0.75s one. The below gesture recognizer is initialized with a |
| + // detection duration a little lower than that (see |
| + // kLongPressDurationSeconds). It also points the delegate to this class |
| + // that |
| + // allows simultaneously operate along with the other recognizers. |
| + _contextMenuRecognizer.reset([[UILongPressGestureRecognizer alloc] |
| + initWithTarget:self |
| + action:@selector(showContextMenu:)]); |
| + [_contextMenuRecognizer setMinimumPressDuration:kLongPressDurationSeconds]; |
| + [_contextMenuRecognizer setAllowableMovement:kLongPressMoveDeltaPixels]; |
| + [_contextMenuRecognizer setDelegate:self]; |
| + [[_webView webView] addGestureRecognizer:_contextMenuRecognizer]; |
| + // Certain system gesture handlers are known to conflict with our context |
| + // menu handler, causing extra events to fire when the context menu is |
| + // active. |
| + |
| + // A number of solutions have been investigated. The lowest-risk solution |
| + // appears to be to recurse through the web controller's recognizers, |
| + // looking |
| + // for fingerprints of the recognizers known to cause problems, which are |
| + // then |
| + // de-prioritized (below our own long click handler). |
| + // Hunting for description fragments of system recognizers is undeniably |
| + // brittle for future versions of iOS. If it does break the context menu |
| + // events may leak (regressing b/5310177), but the app will otherwise work. |
| + [CRWContextMenuController |
| + requireGestureRecognizerToFail:_contextMenuRecognizer |
| + inView:[_webView webView] |
| + containingDescription: |
| + @"action=_highlightLongPressRecognized:"]; |
| + } |
| + return self; |
| +} |
| + |
| +- (UIScrollView*)webScrollView { |
| + return [_webView webScrollView]; |
| +} |
| + |
| +- (CGPoint)scrollPosition { |
| + CGPoint position = CGPointMake(0.0, 0.0); |
| + if (!self.webScrollView) |
|
Eugene But (OOO till 7-30)
2017/01/13 07:16:27
Can this be nil?
Olivier
2017/01/13 10:20:26
I don't think so but documentation on this propert
|
| + return position; |
| + return self.webScrollView.contentOffset; |
| +} |
| + |
| +- (void)showContextMenu:(UIGestureRecognizer*)gestureRecognizer { |
| + // We don't want ongoing notification that the long press is held. |
|
Eugene But (OOO till 7-30)
2017/01/13 07:16:27
nit: Could you please remove "we"
Olivier
2017/01/13 10:20:25
Done.
|
| + if ([gestureRecognizer state] != UIGestureRecognizerStateBegan) |
| + return; |
| + |
| + if (![_DOMElementForLastTouch count]) |
| + return; |
| + |
| + web::ContextMenuParams params = |
| + [self contextMenuParamsForElement:_DOMElementForLastTouch.get()]; |
| + params.view.reset([[_webView webView] retain]); |
| + params.location = [gestureRecognizer locationInView:[_webView webView]]; |
| + if ([_delegate handleContextMenu:params]) { |
| + // Cancelling all touches has the intended side effect of suppressing the |
| + // system's context menu. |
| + [self cancelAllTouches]; |
| + } |
| +} |
| + |
| +// Set the specified recognizer to take priority over any recognizers in the |
|
Eugene But (OOO till 7-30)
2017/01/13 07:16:27
If this method is needed (I personally think it's
Olivier
2017/01/13 10:20:25
I checked. There seems to be no gesture recognizer
Olivier
2017/01/13 10:20:26
Done.
|
| +// view that have a description containing the specified text fragment. |
| ++ (void)requireGestureRecognizerToFail:(UIGestureRecognizer*)recognizer |
| + inView:(UIView*)view |
| + containingDescription:(NSString*)fragment { |
| + for (UIGestureRecognizer* iRecognizer in [view gestureRecognizers]) { |
| + if (iRecognizer != recognizer) { |
| + NSString* description = [iRecognizer description]; |
| + if ([description rangeOfString:fragment].length) { |
| + [iRecognizer requireGestureRecognizerToFail:recognizer]; |
|
Eugene But (OOO till 7-30)
2017/01/13 07:16:27
Do we even get here on iOS9/10?
Olivier
2017/01/13 10:20:25
Tested and apparently no (iphone iOS9/10).
|
| + // requireGestureRecognizerToFail: doesn't retain the recognizer, so it |
| + // is possible for |iRecognizer| to outlive |recognizer| and end up with |
| + // a dangling pointer. Add a retaining associative reference to ensure |
| + // that the lifetimes work out. |
| + // Note that normally using the value as the key wouldn't make any |
| + // sense, but here it's fine since nothing needs to look up the value. |
| + objc_setAssociatedObject(view, recognizer, recognizer, |
| + OBJC_ASSOCIATION_RETAIN_NONATOMIC); |
| + } |
| + } |
| + } |
| +} |
| + |
| +- (web::ContextMenuParams)contextMenuParamsForElement:(NSDictionary*)element { |
| + web::ContextMenuParams params; |
| + NSString* title = nil; |
| + NSString* href = element[@"href"]; |
| + if (href) { |
| + params.link_url = GURL(base::SysNSStringToUTF8(href)); |
| + if (params.link_url.SchemeIs(url::kJavaScriptScheme)) { |
| + title = @"JavaScript"; |
| + } else { |
| + base::string16 URLText = url_formatter::FormatUrl(params.link_url); |
| + title = base::SysUTF16ToNSString(URLText); |
| + } |
| + } |
| + NSString* src = element[@"src"]; |
| + if (src) { |
| + params.src_url = GURL(base::SysNSStringToUTF8(src)); |
| + if (!title) |
| + title = [[src copy] autorelease]; |
| + if ([title hasPrefix:base::SysUTF8ToNSString(url::kDataScheme)]) |
| + title = nil; |
| + } |
| + NSString* titleAttribute = element[@"title"]; |
| + if (titleAttribute) |
| + title = titleAttribute; |
| + if (title) { |
| + params.menu_title.reset([title copy]); |
| + } |
| + NSString* referrerPolicy = element[@"referrerPolicy"]; |
| + if (referrerPolicy) { |
| + params.referrer_policy = |
| + web::ReferrerPolicyFromString(base::SysNSStringToUTF8(referrerPolicy)); |
| + } |
| + NSString* innerText = element[@"innerText"]; |
| + if ([innerText length] > 0) { |
| + params.link_text.reset([innerText copy]); |
| + } |
| + return params; |
| +} |
| + |
| +- (void)cancelAllTouches { |
| + // Disable web view scrolling. |
| + CancelTouches(self.webScrollView.panGestureRecognizer); |
| + |
| + // All user gestures are handled by a subview of web view scroll view |
| + // (WKContentView). |
| + for (UIView* subview in self.webScrollView.subviews) { |
| + for (UIGestureRecognizer* recognizer in subview.gestureRecognizers) { |
| + CancelTouches(recognizer); |
| + } |
| + } |
| + |
| + // Just disabling/enabling the gesture recognizers is not enough to suppress |
| + // the click handlers on the JS side. This JS performs the function of |
| + // suppressing these handlers on the JS side. |
| + NSString* suppressNextClick = @"__gCrWeb.suppressNextClick()"; |
| + [_webView executeJavaScript:suppressNextClick completionHandler:nil]; |
| +} |
| + |
| +- (void)setDOMElementForLastTouch:(NSDictionary*)element { |
| + _DOMElementForLastTouch.reset([element copy]); |
| +} |
| + |
| +#pragma mark - |
| +#pragma mark UIGestureRecognizerDelegate |
| + |
| +- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer |
| + shouldRecognizeSimultaneouslyWithGestureRecognizer: |
| + (UIGestureRecognizer*)otherGestureRecognizer { |
| + // Allows the custom UILongPressGestureRecognizer to fire simultaneously with |
| + // other recognizers. |
| + return YES; |
| +} |
| + |
| +- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer |
| + shouldReceiveTouch:(UITouch*)touch { |
| + // Expect only _contextMenuRecognizer. |
| + DCHECK([gestureRecognizer isEqual:_contextMenuRecognizer]); |
| + |
| + // This is custom long press gesture recognizer. By the time the gesture is |
| + // recognized the web controller needs to know if there is a link under the |
| + // touch. If there a link, the web controller will reject system's context |
| + // menu and show another one. If for some reason context menu info is not |
| + // fetched - system context menu will be shown. |
| + [self setDOMElementForLastTouch:nil]; |
| + base::WeakNSObject<CRWContextMenuController> weakSelf(self); |
| + [self fetchDOMElementAtPoint:[touch locationInView:[_webView webView]] |
| + completionHandler:^(NSDictionary* element) { |
| + [weakSelf setDOMElementForLastTouch:element]; |
| + }]; |
| + return YES; |
| +} |
| + |
| +- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer*)gestureRecognizer { |
| + // Expect only _contextMenuRecognizer. |
| + DCHECK([gestureRecognizer isEqual:_contextMenuRecognizer]); |
| + if (!_webView) { |
| + // Show the context menu iff currently displaying a web view. |
| + // Do nothing for native views. |
|
Eugene But (OOO till 7-30)
2017/01/13 07:16:27
Is this early return necessary? Comments seems to
Olivier
2017/01/13 10:20:26
No, removed.
|
| + return NO; |
| + } |
| + |
| + // Fetching is considered as successful even if |_DOMElementForLastTouch| is |
| + // empty. However if |_DOMElementForLastTouch| is empty then custom context |
| + // menu should not be shown. |
| + UMA_HISTOGRAM_BOOLEAN("WebController.FetchContextMenuInfoAsyncSucceeded", |
| + !!_DOMElementForLastTouch); |
| + return [_DOMElementForLastTouch count]; |
| +} |
| + |
| +#pragma mark - |
| +#pragma mark Web Page Features |
| + |
| +- (void)fetchWebPageWidthWithCompletionHandler:(void (^)(CGFloat))handler { |
| + if (!_webView) { |
|
Eugene But (OOO till 7-30)
2017/01/13 07:16:27
Can this be nil?
Olivier
2017/01/13 10:20:25
Done.
|
| + handler(0); |
| + return; |
| + } |
| + |
| + [_webView |
| + executeJavaScript:@"__gCrWeb.getPageWidth();" |
| + completionHandler:^(id pageWidth, NSError*) { |
| + handler([base::mac::ObjCCastStrict<NSNumber>(pageWidth) floatValue]); |
| + }]; |
| +} |
| + |
| +- (void)fetchDOMElementAtPoint:(CGPoint)point |
| + completionHandler:(void (^)(NSDictionary*))handler { |
| + DCHECK(handler); |
| + // Convert point into web page's coordinate system (which may be scaled and/or |
| + // scrolled). |
| + CGPoint scrollOffset = self.scrollPosition; |
| + CGFloat webViewContentWidth = self.webScrollView.contentSize.width; |
| + base::WeakNSObject<CRWContextMenuController> weakSelf(self); |
| + [self fetchWebPageWidthWithCompletionHandler:^(CGFloat pageWidth) { |
| + CGFloat scale = pageWidth / webViewContentWidth; |
| + CGPoint localPoint = CGPointMake((point.x + scrollOffset.x) * scale, |
| + (point.y + scrollOffset.y) * scale); |
| + NSString* const kGetElementScript = |
| + [NSString stringWithFormat:@"__gCrWeb.getElementFromPoint(%g, %g);", |
| + localPoint.x, localPoint.y]; |
| + [[weakSelf webView] |
| + executeJavaScript:kGetElementScript |
| + completionHandler:^(id element, NSError*) { |
| + handler(base::mac::ObjCCastStrict<NSDictionary>(element)); |
| + }]; |
| + }]; |
| +} |
| + |
| +@end |