Title: [241971] trunk
Revision
241971
Author
[email protected]
Date
2019-02-22 16:48:16 -0800 (Fri, 22 Feb 2019)

Log Message

[iOS] Callout menu overlaps in-page controls when editing a comment in github.com's issue tracker
https://bugs.webkit.org/show_bug.cgi?id=194873
<rdar://problem/46701974>

Reviewed by Tim Horton.

Source/WebKit:

On the topic of supporting web-based rich text editors on iOS, one problematic area has always been handling
conflicts between platform UI (i.e., the system callout menu) and in-page text editing controls. This issue
comes up in websites that don't use the "hidden contenteditable" approach to rich text editing, but also show
additional controls in a toolbar or contextual menu above the selection. In these cases, what often happens is
that system controls overlap controls in the page.

Luckily, the iOS callout menu (i.e. the private UICalloutBar) is capable of presenting with a list of "evasion
rects" to avoid; if the callout bar would normally intersect with one of these rects, then a different
orientation that does not intersect with one of these rects is chosen instead. Currently, the only rect added
here by UIKit when presenting the callout menu is the bounding rect of the on-screen keyboard, but after
<rdar://problem/48128337>, we now have a generalized mechanism for offering additional evasion rects before
UIKit presents the callout menu.

This patch adopts the mechanism introduced in <rdar://problem/48128337>, and introduces a heuristic for
determining the approximate location of controls in the page which might overlap the callout menu. This
heuristic works by hit-testing for clickable (but non-editable) nodes above the bounds of the selection, which
are additionally not hit-tested by advancing outwards from any of the other edges of the selection bounds.
Additionally, any hit-tested nodes whose bounding rects are very large (relative to the content view size) are
ignored (this deals with scenarios where the body or a large container element has a click handler). We then add
the bounding rects of each of the nodes that fit this criteria to the list of rects for UIKit to avoid when
presenting the system callout menu.

The result is that WebKit will, by default, avoid overlapping anything that looks like controls in the page when
showing a callout menu in editable content. In practice, this fixes overlapping controls on most websites that
roll their own context menu or toolbar in their rich text editor.

Test: editing/selection/ios/avoid-showing-callout-menu-over-controls.html

* Platform/spi/ios/UIKitSPI.h:
* UIProcess/WebPageProxy.h:
* UIProcess/ios/WKContentViewInteraction.mm:
(-[WKContentView requestAutocorrectionRectsForString:withCompletionHandler:]):
(-[WKContentView requestRectsToEvadeForSelectionCommandsWithCompletionHandler:]):
(-[WKContentView requestAutocorrectionContextWithCompletionHandler:]):

Drive-by: handle null completion handler arguments more gracefully, by raising an NSException and bailing before
attempting to invoke a nil block.

* UIProcess/ios/WebPageProxyIOS.mm:
(WebKit::WebPageProxy::requestEvasionRectsAboveSelection):

See above for more detail.

* WebProcess/WebPage/WebPage.h:
* WebProcess/WebPage/WebPage.messages.in:
* WebProcess/WebPage/ios/WebPageIOS.mm:
(WebKit::WebPage::requestEvasionRectsAboveSelection):

Tools:

Add a couple of UIScriptController methods to make callout menu testing on iOS easier (see below).

* DumpRenderTree/ios/UIScriptControllerIOS.mm:
(WTR::UIScriptController::menuRect const):
(WTR::UIScriptController::isShowingMenu const):
* TestRunnerShared/UIScriptContext/Bindings/UIScriptController.idl:
* TestRunnerShared/UIScriptContext/UIScriptController.cpp:
(WTR::UIScriptController::menuRect const):

Add a function to query the bounds of the callout menu in content coordinates.

(WTR::UIScriptController::isShowingMenu const):

Add a function to query whether the callout menu is shown (i.e., has finished its appearance animation).

* TestRunnerShared/UIScriptContext/UIScriptController.h:
* WebKitTestRunner/cocoa/TestRunnerWKWebView.h:
* WebKitTestRunner/ios/UIScriptControllerIOS.mm:
(WTR::UIScriptController::rectForMenuAction const):
(WTR::UIScriptController::menuRect const):
(WTR::UIScriptController::isShowingMenu const):
(WTR::findViewInHierarchyOfType): Deleted.

LayoutTests:

Add a test to ensure that the we dodge clickable elements when showing the callout bar.

* editing/selection/ios/avoid-showing-callout-menu-over-controls-expected.txt: Added.
* editing/selection/ios/avoid-showing-callout-menu-over-controls.html: Added.
* resources/ui-helper.js:
(window.UIHelper.waitForMenuToShow.return.new.Promise):
(window.UIHelper.waitForMenuToShow):
(window.UIHelper.menuRect):
(window.UIHelper):

Modified Paths

Added Paths

Diff

Modified: trunk/LayoutTests/ChangeLog (241970 => 241971)


--- trunk/LayoutTests/ChangeLog	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/LayoutTests/ChangeLog	2019-02-23 00:48:16 UTC (rev 241971)
@@ -1,5 +1,23 @@
 2019-02-22  Wenson Hsieh  <[email protected]>
 
+        [iOS] Callout menu overlaps in-page controls when editing a comment in github.com's issue tracker
+        https://bugs.webkit.org/show_bug.cgi?id=194873
+        <rdar://problem/46701974>
+
+        Reviewed by Tim Horton.
+
+        Add a test to ensure that the we dodge clickable elements when showing the callout bar.
+
+        * editing/selection/ios/avoid-showing-callout-menu-over-controls-expected.txt: Added.
+        * editing/selection/ios/avoid-showing-callout-menu-over-controls.html: Added.
+        * resources/ui-helper.js:
+        (window.UIHelper.waitForMenuToShow.return.new.Promise):
+        (window.UIHelper.waitForMenuToShow):
+        (window.UIHelper.menuRect):
+        (window.UIHelper):
+
+2019-02-22  Wenson Hsieh  <[email protected]>
+
         Input type "formatSetInlineTextDirection" is dispatched when changing paragraph-level text direction
         https://bugs.webkit.org/show_bug.cgi?id=194703
         <rdar://problem/48111775>

Added: trunk/LayoutTests/editing/selection/ios/avoid-showing-callout-menu-over-controls-expected.txt (0 => 241971)


--- trunk/LayoutTests/editing/selection/ios/avoid-showing-callout-menu-over-controls-expected.txt	                        (rev 0)
+++ trunk/LayoutTests/editing/selection/ios/avoid-showing-callout-menu-over-controls-expected.txt	2019-02-23 00:48:16 UTC (rev 241971)
@@ -0,0 +1,12 @@
+              
+
+Verifies that we don't show the system callout menu over controls. To test manually: focus the editable document in the iframe, select some text, and confirm that the callout bar is shown beneath the selection instead of above the selection.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS centerY(grabberRect) < centerY(menuRect) is true
+PASS successfullyParsed is true
+
+TEST COMPLETE
+

Added: trunk/LayoutTests/editing/selection/ios/avoid-showing-callout-menu-over-controls.html (0 => 241971)


--- trunk/LayoutTests/editing/selection/ios/avoid-showing-callout-menu-over-controls.html	                        (rev 0)
+++ trunk/LayoutTests/editing/selection/ios/avoid-showing-callout-menu-over-controls.html	2019-02-23 00:48:16 UTC (rev 241971)
@@ -0,0 +1,76 @@
+<!DOCTYPE html> <!-- webkit-test-runner [ useFlexibleViewport=true ] -->
+<html>
+<head>
+<script src=""
+<script src=""
+<meta name=viewport content="width=device-width, initial-scale=1">
+<style>
+body, html {
+    width: 100%;
+    height: 100%;
+    margin: 0;
+}
+
+iframe {
+    width: 320px;
+    height: 100px;
+}
+
+#controls {
+    margin-top: 20px;
+    margin-bottom: 10px;
+}
+
+input[type=button] {
+    height: 1em;
+}
+</style>
+<script>
+jsTestIsAsync = true;
+
+async function runTest() {
+    description("Verifies that we don't show the system callout menu over controls. To test manually: focus the editable document in the iframe, select some text, and confirm that the callout bar is shown beneath the selection instead of above the selection.");
+
+    centerX = rect => rect.left + rect.width / 2;
+    centerY = rect => rect.top + rect.height / 2;
+
+    await UIHelper.activateAndWaitForInputSessionAt(160, 100);
+
+    const iframeDocument = document.querySelector("iframe").contentDocument;
+    iframeDocument.getSelection().selectAllChildren(iframeDocument.body);
+
+    grabberRect = {};
+    while (!grabberRect.width && !grabberRect.height)
+        grabberRect = await UIHelper.getSelectionEndGrabberViewRect();
+
+    await UIHelper.activateAt(centerX(grabberRect), centerY(grabberRect));
+    await UIHelper.waitForMenuToShow();
+    menuRect = await UIHelper.menuRect();
+
+    document.activeElement.blur();
+
+    shouldBe("centerY(grabberRect) < centerY(menuRect)", "true");
+    finishJSTest();
+}
+
+addEventListener("load", runTest);
+</script>
+</head>
+<body>
+<div id="controls">
+    <input type="button" value="1"></input>
+    <input type="button" value="2"></input>
+    <input type="button" value="3"></input>
+    <input type="button" value="4"></input>
+    <input type="button" value="5"></input>
+    <input type="button" value="6"></input>
+    <input type="button" value="7"></input>
+    <input type="button" value="8"></input>
+</div>
+<div>
+    <iframe srcdoc="<body contenteditable>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod.</body>"></iframe>
+</div>
+<p id="description"></p>
+<p id="console"></p>
+</body>
+</html>
\ No newline at end of file

Modified: trunk/LayoutTests/resources/ui-helper.js (241970 => 241971)


--- trunk/LayoutTests/resources/ui-helper.js	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/LayoutTests/resources/ui-helper.js	2019-02-23 00:48:16 UTC (rev 241971)
@@ -665,4 +665,24 @@
         const script = "JSON.stringify([uiController.lastUndoLabel, uiController.firstRedoLabel])";
         return new Promise(resolve => testRunner.runUIScript(script, result => resolve(JSON.parse(result))));
     }
+
+    static waitForMenuToShow()
+    {
+        return new Promise(resolve => {
+            testRunner.runUIScript(`
+                (function() {
+                    if (!uiController.isShowingMenu)
+                        uiController.didShowMenuCallback = () => uiController.uiScriptComplete();
+                    else
+                        uiController.uiScriptComplete();
+                })()`, resolve);
+        });
+    }
+
+    static menuRect()
+    {
+        return new Promise(resolve => {
+            testRunner.runUIScript("JSON.stringify(uiController.menuRect)", result => resolve(JSON.parse(result)));
+        });
+    }
 }

Modified: trunk/Source/WebKit/ChangeLog (241970 => 241971)


--- trunk/Source/WebKit/ChangeLog	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Source/WebKit/ChangeLog	2019-02-23 00:48:16 UTC (rev 241971)
@@ -1,3 +1,59 @@
+2019-02-22  Wenson Hsieh  <[email protected]>
+
+        [iOS] Callout menu overlaps in-page controls when editing a comment in github.com's issue tracker
+        https://bugs.webkit.org/show_bug.cgi?id=194873
+        <rdar://problem/46701974>
+
+        Reviewed by Tim Horton.
+
+        On the topic of supporting web-based rich text editors on iOS, one problematic area has always been handling
+        conflicts between platform UI (i.e., the system callout menu) and in-page text editing controls. This issue
+        comes up in websites that don't use the "hidden contenteditable" approach to rich text editing, but also show
+        additional controls in a toolbar or contextual menu above the selection. In these cases, what often happens is
+        that system controls overlap controls in the page.
+
+        Luckily, the iOS callout menu (i.e. the private UICalloutBar) is capable of presenting with a list of "evasion
+        rects" to avoid; if the callout bar would normally intersect with one of these rects, then a different
+        orientation that does not intersect with one of these rects is chosen instead. Currently, the only rect added
+        here by UIKit when presenting the callout menu is the bounding rect of the on-screen keyboard, but after
+        <rdar://problem/48128337>, we now have a generalized mechanism for offering additional evasion rects before
+        UIKit presents the callout menu.
+
+        This patch adopts the mechanism introduced in <rdar://problem/48128337>, and introduces a heuristic for
+        determining the approximate location of controls in the page which might overlap the callout menu. This
+        heuristic works by hit-testing for clickable (but non-editable) nodes above the bounds of the selection, which
+        are additionally not hit-tested by advancing outwards from any of the other edges of the selection bounds.
+        Additionally, any hit-tested nodes whose bounding rects are very large (relative to the content view size) are
+        ignored (this deals with scenarios where the body or a large container element has a click handler). We then add
+        the bounding rects of each of the nodes that fit this criteria to the list of rects for UIKit to avoid when
+        presenting the system callout menu.
+
+        The result is that WebKit will, by default, avoid overlapping anything that looks like controls in the page when
+        showing a callout menu in editable content. In practice, this fixes overlapping controls on most websites that
+        roll their own context menu or toolbar in their rich text editor.
+
+        Test: editing/selection/ios/avoid-showing-callout-menu-over-controls.html
+
+        * Platform/spi/ios/UIKitSPI.h:
+        * UIProcess/WebPageProxy.h:
+        * UIProcess/ios/WKContentViewInteraction.mm:
+        (-[WKContentView requestAutocorrectionRectsForString:withCompletionHandler:]):
+        (-[WKContentView requestRectsToEvadeForSelectionCommandsWithCompletionHandler:]):
+        (-[WKContentView requestAutocorrectionContextWithCompletionHandler:]):
+
+        Drive-by: handle null completion handler arguments more gracefully, by raising an NSException and bailing before
+        attempting to invoke a nil block.
+
+        * UIProcess/ios/WebPageProxyIOS.mm:
+        (WebKit::WebPageProxy::requestEvasionRectsAboveSelection):
+
+        See above for more detail.
+
+        * WebProcess/WebPage/WebPage.h:
+        * WebProcess/WebPage/WebPage.messages.in:
+        * WebProcess/WebPage/ios/WebPageIOS.mm:
+        (WebKit::WebPage::requestEvasionRectsAboveSelection):
+
 2019-02-22  Simon Fraser  <[email protected]>
 
         Unreviewed build fix: need a WebCore:: namespace on ScrollingEventResult.

Modified: trunk/Source/WebKit/Platform/spi/ios/UIKitSPI.h (241970 => 241971)


--- trunk/Source/WebKit/Platform/spi/ios/UIKitSPI.h	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Source/WebKit/Platform/spi/ios/UIKitSPI.h	2019-02-23 00:48:16 UTC (rev 241971)
@@ -988,6 +988,7 @@
 #endif
 
 @interface UICalloutBar : UIView
++ (UICalloutBar *)activeCalloutBar;
 + (void)fadeSharedCalloutBar;
 @end
 

Modified: trunk/Source/WebKit/UIProcess/WebPageProxy.h (241970 => 241971)


--- trunk/Source/WebKit/UIProcess/WebPageProxy.h	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Source/WebKit/UIProcess/WebPageProxy.h	2019-02-23 00:48:16 UTC (rev 241971)
@@ -687,6 +687,7 @@
     void cancelAutoscroll();
     void hardwareKeyboardAvailabilityChanged();
     bool isScrollingOrZooming() const { return m_isScrollingOrZooming; }
+    void requestEvasionRectsAboveSelection(CompletionHandler<void(const Vector<WebCore::FloatRect>&)>&&);
 #if ENABLE(DATA_INTERACTION)
     void didHandleDragStartRequest(bool started);
     void didHandleAdditionalDragItemsRequest(bool added);

Modified: trunk/Source/WebKit/UIProcess/ios/WKContentViewInteraction.mm (241970 => 241971)


--- trunk/Source/WebKit/UIProcess/ios/WKContentViewInteraction.mm	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Source/WebKit/UIProcess/ios/WKContentViewInteraction.mm	2019-02-23 00:48:16 UTC (rev 241971)
@@ -3353,9 +3353,13 @@
 // The completion handler can pass nil if input does not match the actual text preceding the insertion point.
 - (void)requestAutocorrectionRectsForString:(NSString *)input withCompletionHandler:(void (^)(UIWKAutocorrectionRects *rectsForInput))completionHandler
 {
+    if (!completionHandler) {
+        [NSException raise:NSInvalidArgumentException format:@"Expected a nonnull completion handler in %s.", __PRETTY_FUNCTION__];
+        return;
+    }
+
     if (!input || ![input length]) {
-        if (completionHandler)
-            completionHandler(nil);
+        completionHandler(nil);
         return;
     }
 
@@ -3373,11 +3377,50 @@
         view->_autocorrectionData.textFirstRect = firstRect;
         view->_autocorrectionData.textLastRect = lastRect;
 
-        if (completion)
-            completion(rects.size() ? [WKAutocorrectionRects autocorrectionRectsWithRects:firstRect lastRect:lastRect] : nil);
+        completion(rects.size() ? [WKAutocorrectionRects autocorrectionRectsWithRects:firstRect lastRect:lastRect] : nil);
     });
 }
 
+- (void)requestRectsToEvadeForSelectionCommandsWithCompletionHandler:(void(^)(NSArray<NSValue *> *rects))completionHandler
+{
+    if (!completionHandler) {
+        [NSException raise:NSInvalidArgumentException format:@"Expected a nonnull completion handler in %s.", __PRETTY_FUNCTION__];
+        return;
+    }
+
+    if ([self _shouldSuppressSelectionCommands] || _webView._editable) {
+        completionHandler(@[ ]);
+        return;
+    }
+
+    if (_focusedElementInformation.elementType != WebKit::InputType::ContentEditable && _focusedElementInformation.elementType != WebKit::InputType::TextArea) {
+        completionHandler(@[ ]);
+        return;
+    }
+
+    // Give the page some time to present custom editing UI before attempting to detect and evade it.
+    auto delayBeforeShowingCalloutBar = (0.25_s).nanoseconds();
+    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delayBeforeShowingCalloutBar), dispatch_get_main_queue(), [completion = makeBlockPtr(completionHandler), weakSelf = WeakObjCPtr<WKContentView>(self)] () mutable {
+        if (!weakSelf) {
+            completion(@[ ]);
+            return;
+        }
+
+        auto strongSelf = weakSelf.get();
+        if (!strongSelf->_page) {
+            completion(@[ ]);
+            return;
+        }
+
+        strongSelf->_page->requestEvasionRectsAboveSelection([completion = WTFMove(completion)] (auto& rects) {
+            auto rectsAsValues = adoptNS([[NSMutableArray alloc] initWithCapacity:rects.size()]);
+            for (auto& floatRect : rects)
+                [rectsAsValues addObject:[NSValue valueWithCGRect:floatRect]];
+            completion(rectsAsValues.get());
+        });
+    });
+}
+
 - (void)selectPositionAtPoint:(CGPoint)point completionHandler:(void (^)(void))completionHandler
 {
     _usingGestureForSelection = YES;
@@ -3537,8 +3580,10 @@
 
 - (void)requestAutocorrectionContextWithCompletionHandler:(void (^)(UIWKAutocorrectionContext *autocorrectionContext))completionHandler
 {
-    if (!completionHandler)
+    if (!completionHandler) {
+        [NSException raise:NSInvalidArgumentException format:@"Expected a nonnull completion handler in %s.", __PRETTY_FUNCTION__];
         return;
+    }
 
 #if USE(UIKIT_KEYBOARD_ADDITIONS)
     if ([self _disableAutomaticKeyboardUI]) {

Modified: trunk/Source/WebKit/UIProcess/ios/WebPageProxyIOS.mm (241970 => 241971)


--- trunk/Source/WebKit/UIProcess/ios/WebPageProxyIOS.mm	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Source/WebKit/UIProcess/ios/WebPageProxyIOS.mm	2019-02-23 00:48:16 UTC (rev 241971)
@@ -29,6 +29,7 @@
 #if PLATFORM(IOS_FAMILY)
 
 #import "APIUIClient.h"
+#import "Connection.h"
 #import "DataReference.h"
 #import "EditingRange.h"
 #import "GlobalFindInPageState.h"
@@ -1112,6 +1113,16 @@
     m_process->send(Messages::WebPage::HardwareKeyboardAvailabilityChanged(), m_pageID);
 }
 
+void WebPageProxy::requestEvasionRectsAboveSelection(CompletionHandler<void(const Vector<WebCore::FloatRect>&)>&& callback)
+{
+    if (!isValid()) {
+        callback({ });
+        return;
+    }
+
+    m_process->connection()->sendWithAsyncReply(Messages::WebPage::RequestEvasionRectsAboveSelection(), WTFMove(callback), m_pageID);
+}
+
 #if ENABLE(DATA_INTERACTION)
 
 void WebPageProxy::didHandleDragStartRequest(bool started)

Modified: trunk/Source/WebKit/WebProcess/WebPage/WebPage.h (241970 => 241971)


--- trunk/Source/WebKit/WebProcess/WebPage/WebPage.h	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Source/WebKit/WebProcess/WebPage/WebPage.h	2019-02-23 00:48:16 UTC (rev 241971)
@@ -664,6 +664,7 @@
     void storeSelectionForAccessibility(bool);
     void startAutoscrollAtPosition(const WebCore::FloatPoint&);
     void cancelAutoscroll();
+    void requestEvasionRectsAboveSelection(CompletionHandler<void(const Vector<WebCore::FloatRect>&)>&&);
 
     void contentSizeCategoryDidChange(const String&);
 

Modified: trunk/Source/WebKit/WebProcess/WebPage/WebPage.messages.in (241970 => 241971)


--- trunk/Source/WebKit/WebProcess/WebPage/WebPage.messages.in	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Source/WebKit/WebProcess/WebPage/WebPage.messages.in	2019-02-23 00:48:16 UTC (rev 241971)
@@ -80,6 +80,7 @@
     SyncApplyAutocorrection(String correction, String originalText) -> (bool autocorrectionApplied) Delayed
     RequestAutocorrectionContext(WebKit::CallbackID callbackID)
     AutocorrectionContextSync() -> (struct WebKit::WebAutocorrectionContext context) Delayed
+    RequestEvasionRectsAboveSelection() -> (Vector<WebCore::FloatRect> rects) Async
     GetPositionInformation(struct WebKit::InteractionInformationRequest request) -> (struct WebKit::InteractionInformationAtPosition information) Delayed
     RequestPositionInformation(struct WebKit::InteractionInformationRequest request)
     StartInteractionWithElementAtPosition(WebCore::IntPoint point)

Modified: trunk/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm (241970 => 241971)


--- trunk/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm	2019-02-23 00:48:16 UTC (rev 241971)
@@ -1504,6 +1504,107 @@
     m_page->mainFrame().eventHandler().cancelSelectionAutoscroll();
 }
 
+void WebPage::requestEvasionRectsAboveSelection(CompletionHandler<void(const Vector<FloatRect>&)>&& reply)
+{
+    auto& frame = m_page->focusController().focusedOrMainFrame();
+    auto frameView = makeRefPtr(frame.view());
+    if (!frameView) {
+        reply({ });
+        return;
+    }
+
+    auto& selection = frame.selection().selection();
+    if (selection.isNone()) {
+        reply({ });
+        return;
+    }
+
+    auto selectedRange = selection.toNormalizedRange();
+    if (!selectedRange) {
+        reply({ });
+        return;
+    }
+
+    if (!m_focusedElement || !m_focusedElement->renderer() || m_focusedElement->renderer()->isTransparentOrFullyClippedRespectingParentFrames()) {
+        reply({ });
+        return;
+    }
+
+    float scaleFactor = pageScaleFactor();
+    const double factorOfContentArea = 0.5;
+    auto unobscuredContentArea = m_page->mainFrame().view()->unobscuredContentRect().area();
+    if (unobscuredContentArea.hasOverflowed()) {
+        reply({ });
+        return;
+    }
+
+    double contextMenuAreaLimit = factorOfContentArea * scaleFactor * unobscuredContentArea.unsafeGet();
+
+    FloatRect selectionBoundsInRootViewCoordinates;
+    if (selection.isRange())
+        selectionBoundsInRootViewCoordinates = frameView->contentsToRootView(selectedRange->absoluteBoundingBox());
+    else
+        selectionBoundsInRootViewCoordinates = frameView->contentsToRootView(frame.selection().absoluteCaretBounds());
+
+    auto centerOfTargetBounds = selectionBoundsInRootViewCoordinates.center();
+    FloatPoint centerTopInRootViewCoordinates { centerOfTargetBounds.x(), selectionBoundsInRootViewCoordinates.y() };
+
+    auto clickableNonEditableNode = [&] (const FloatPoint& locationInRootViewCoordinates) -> Node* {
+        FloatPoint adjustedPoint;
+        auto* hitNode = m_page->mainFrame().nodeRespondingToClickEvents(locationInRootViewCoordinates, adjustedPoint);
+        if (!hitNode || is<HTMLBodyElement>(hitNode) || is<Document>(hitNode) || hitNode->hasEditableStyle())
+            return nullptr;
+
+        return hitNode;
+    };
+
+    // This heuristic attempts to find a list of rects to avoid when showing the callout menu on iOS.
+    // First, hit-test several points above the bounds of the selection rect in search of clickable nodes that are not editable.
+    // Secondly, hit-test several points around the edges of the selection rect and exclude any nodes found in the first round of
+    // hit-testing if these nodes are also reachable by moving outwards from the left, right, or bottom edges of the selection.
+    // Additionally, exclude any hit-tested nodes that are either very large relative to the size of the root view, or completely
+    // encompass the selection bounds. The resulting rects are the bounds of these hit-tested nodes in root view coordinates.
+    HashSet<Ref<Node>> hitTestedNodes;
+    Vector<FloatRect> rectsToAvoidInRootViewCoordinates;
+    const Vector<FloatPoint, 5> offsetsForHitTesting {{ -30, -50 }, { 30, -50 }, { -60, -35 }, { 60, -35 }, { 0, -20 }};
+    for (auto offset : offsetsForHitTesting) {
+        offset.scale(1 / scaleFactor);
+        if (auto* hitNode = clickableNonEditableNode(centerTopInRootViewCoordinates + offset))
+            hitTestedNodes.add(*hitNode);
+    }
+
+    const float marginForHitTestingSurroundingNodes = 80 / scaleFactor;
+    Vector<FloatPoint, 3> exclusionHitTestLocations {
+        { selectionBoundsInRootViewCoordinates.x() - marginForHitTestingSurroundingNodes, centerOfTargetBounds.y() },
+        { centerOfTargetBounds.x(), selectionBoundsInRootViewCoordinates.maxY() + marginForHitTestingSurroundingNodes },
+        { selectionBoundsInRootViewCoordinates.maxX() + marginForHitTestingSurroundingNodes, centerOfTargetBounds.y() }
+    };
+
+    for (auto& location : exclusionHitTestLocations) {
+        if (auto* nodeToExclude = clickableNonEditableNode(location))
+            hitTestedNodes.remove(*nodeToExclude);
+    }
+
+    for (auto& node : hitTestedNodes) {
+        auto frameView = makeRefPtr(node->document().view());
+        auto* renderer = node->renderer();
+        if (!renderer || !frameView)
+            continue;
+
+        auto bounds = frameView->contentsToRootView(renderer->absoluteBoundingBoxRect());
+        auto area = bounds.area();
+        if (area.hasOverflowed() || area.unsafeGet() > contextMenuAreaLimit)
+            continue;
+
+        if (bounds.contains(enclosingIntRect(selectionBoundsInRootViewCoordinates)))
+            continue;
+
+        rectsToAvoidInRootViewCoordinates.append(WTFMove(bounds));
+    }
+
+    reply(WTFMove(rectsToAvoidInRootViewCoordinates));
+}
+
 void WebPage::getRectsForGranularityWithSelectionOffset(uint32_t granularity, int32_t offset, CallbackID callbackID)
 {
     Frame& frame = m_page->focusController().focusedOrMainFrame();

Modified: trunk/Tools/ChangeLog (241970 => 241971)


--- trunk/Tools/ChangeLog	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Tools/ChangeLog	2019-02-23 00:48:16 UTC (rev 241971)
@@ -1,3 +1,34 @@
+2019-02-22  Wenson Hsieh  <[email protected]>
+
+        [iOS] Callout menu overlaps in-page controls when editing a comment in github.com's issue tracker
+        https://bugs.webkit.org/show_bug.cgi?id=194873
+        <rdar://problem/46701974>
+
+        Reviewed by Tim Horton.
+
+        Add a couple of UIScriptController methods to make callout menu testing on iOS easier (see below).
+
+        * DumpRenderTree/ios/UIScriptControllerIOS.mm:
+        (WTR::UIScriptController::menuRect const):
+        (WTR::UIScriptController::isShowingMenu const):
+        * TestRunnerShared/UIScriptContext/Bindings/UIScriptController.idl:
+        * TestRunnerShared/UIScriptContext/UIScriptController.cpp:
+        (WTR::UIScriptController::menuRect const):
+
+        Add a function to query the bounds of the callout menu in content coordinates.
+
+        (WTR::UIScriptController::isShowingMenu const):
+
+        Add a function to query whether the callout menu is shown (i.e., has finished its appearance animation).
+
+        * TestRunnerShared/UIScriptContext/UIScriptController.h:
+        * WebKitTestRunner/cocoa/TestRunnerWKWebView.h:
+        * WebKitTestRunner/ios/UIScriptControllerIOS.mm:
+        (WTR::UIScriptController::rectForMenuAction const):
+        (WTR::UIScriptController::menuRect const):
+        (WTR::UIScriptController::isShowingMenu const):
+        (WTR::findViewInHierarchyOfType): Deleted.
+
 2019-02-22  Chris Dumez  <[email protected]>
 
         Regression(PSON) Unable to preview password-protected documents on iCloud.com

Modified: trunk/Tools/DumpRenderTree/ios/UIScriptControllerIOS.mm (241970 => 241971)


--- trunk/Tools/DumpRenderTree/ios/UIScriptControllerIOS.mm	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Tools/DumpRenderTree/ios/UIScriptControllerIOS.mm	2019-02-23 00:48:16 UTC (rev 241971)
@@ -337,6 +337,16 @@
     return nullptr;
 }
 
+JSObjectRef UIScriptController::menuRect() const
+{
+    return nullptr;
+}
+
+bool UIScriptController::isShowingMenu() const
+{
+    return false;
+}
+
 void UIScriptController::platformSetDidEndScrollingCallback()
 {
 }

Modified: trunk/Tools/TestRunnerShared/UIScriptContext/Bindings/UIScriptController.idl (241970 => 241971)


--- trunk/Tools/TestRunnerShared/UIScriptContext/Bindings/UIScriptController.idl	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Tools/TestRunnerShared/UIScriptContext/Bindings/UIScriptController.idl	2019-02-23 00:48:16 UTC (rev 241971)
@@ -225,6 +225,8 @@
 
     attribute object didShowMenuCallback;
     attribute object didHideMenuCallback;
+    readonly attribute boolean isShowingMenu;
+    readonly attribute object menuRect;
     object rectForMenuAction(DOMString action);
 
     attribute object willBeginZoomingCallback;

Modified: trunk/Tools/TestRunnerShared/UIScriptContext/UIScriptController.cpp (241970 => 241971)


--- trunk/Tools/TestRunnerShared/UIScriptContext/UIScriptController.cpp	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Tools/TestRunnerShared/UIScriptContext/UIScriptController.cpp	2019-02-23 00:48:16 UTC (rev 241971)
@@ -532,11 +532,21 @@
 {
 }
 
+JSObjectRef UIScriptController::menuRect() const
+{
+    return nullptr;
+}
+
 JSObjectRef UIScriptController::rectForMenuAction(JSStringRef) const
 {
     return nullptr;
 }
 
+bool UIScriptController::isShowingMenu() const
+{
+    return false;
+}
+
 void UIScriptController::platformClearAllCallbacks()
 {
 }

Modified: trunk/Tools/TestRunnerShared/UIScriptContext/UIScriptController.h (241970 => 241971)


--- trunk/Tools/TestRunnerShared/UIScriptContext/UIScriptController.h	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Tools/TestRunnerShared/UIScriptContext/UIScriptController.h	2019-02-23 00:48:16 UTC (rev 241971)
@@ -164,7 +164,9 @@
     void setDidShowMenuCallback(JSValueRef);
     JSValueRef didShowMenuCallback() const;
 
+    bool isShowingMenu() const;
     JSObjectRef rectForMenuAction(JSStringRef action) const;
+    JSObjectRef menuRect() const;
 
     void setDidEndScrollingCallback(JSValueRef);
     JSValueRef didEndScrollingCallback() const;

Modified: trunk/Tools/WebKitTestRunner/cocoa/TestRunnerWKWebView.h (241970 => 241971)


--- trunk/Tools/WebKitTestRunner/cocoa/TestRunnerWKWebView.h	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Tools/WebKitTestRunner/cocoa/TestRunnerWKWebView.h	2019-02-23 00:48:16 UTC (rev 241971)
@@ -56,6 +56,7 @@
 @property (nonatomic, assign) UIEdgeInsets overrideSafeAreaInsets;
 
 @property (nonatomic, readonly, getter=isShowingKeyboard) BOOL showingKeyboard;
+@property (nonatomic, readonly, getter=isShowingMenu) BOOL showingMenu;
 @property (nonatomic, assign) BOOL usesSafariLikeRotation;
 @property (nonatomic, readonly, getter=isInteractingWithFormControl) BOOL interactingWithFormControl;
 

Modified: trunk/Tools/WebKitTestRunner/ios/UIScriptControllerIOS.mm (241970 => 241971)


--- trunk/Tools/WebKitTestRunner/ios/UIScriptControllerIOS.mm	2019-02-23 00:41:58 UTC (rev 241970)
+++ trunk/Tools/WebKitTestRunner/ios/UIScriptControllerIOS.mm	2019-02-23 00:48:16 UTC (rev 241971)
@@ -117,19 +117,6 @@
     return stop;
 }
 
-static UIView *findViewInHierarchyOfType(UIView *view, Class viewClass)
-{
-    __block RetainPtr<UIView> foundView;
-    forEachViewInHierarchy(view, ^(UIView *subview, BOOL *stop) {
-        if (![subview isKindOfClass:viewClass])
-            return;
-
-        foundView = subview;
-        *stop = YES;
-    });
-    return foundView.autorelease();
-}
-
 static NSArray<UIView *> *findAllViewsInHierarchyOfType(UIView *view, Class viewClass)
 {
     __block RetainPtr<NSMutableArray> views = adoptNS([[NSMutableArray alloc] init]);
@@ -883,25 +870,21 @@
 
     UIWindow *windowForButton = nil;
     UIButton *buttonForAction = nil;
-    for (UIWindow *window in UIApplication.sharedApplication.windows) {
-        if (![window isKindOfClass:UITextEffectsWindow.class])
+    UIView *calloutBar = UICalloutBar.activeCalloutBar;
+    if (!calloutBar.window)
+        return nullptr;
+
+    for (UIButton *button in findAllViewsInHierarchyOfType(calloutBar, UIButton.class)) {
+        NSString *buttonTitle = [button titleForState:UIControlStateNormal];
+        if (!buttonTitle.length)
             continue;
 
-        UIView *calloutBar = findViewInHierarchyOfType(window, UICalloutBar.class);
-        if (!calloutBar)
+        if (![buttonTitle isEqualToString:(__bridge NSString *)action.get()])
             continue;
 
-        for (UIButton *button in findAllViewsInHierarchyOfType(calloutBar, UIButton.class)) {
-            NSString *buttonTitle = [button titleForState:UIControlStateNormal];
-            if (!buttonTitle.length)
-                continue;
-
-            if (![buttonTitle isEqualToString:(__bridge NSString *)action.get()])
-                continue;
-
-            buttonForAction = button;
-            windowForButton = window;
-        }
+        buttonForAction = button;
+        windowForButton = calloutBar.window;
+        break;
     }
 
     if (!buttonForAction)
@@ -911,6 +894,21 @@
     return m_context->objectFromRect(WebCore::FloatRect(rectInRootViewCoordinates.origin.x, rectInRootViewCoordinates.origin.y, rectInRootViewCoordinates.size.width, rectInRootViewCoordinates.size.height));
 }
 
+JSObjectRef UIScriptController::menuRect() const
+{
+    UIView *calloutBar = UICalloutBar.activeCalloutBar;
+    if (!calloutBar.window)
+        return nullptr;
+
+    CGRect rectInRootViewCoordinates = [calloutBar convertRect:calloutBar.bounds toView:platformContentView()];
+    return m_context->objectFromRect(WebCore::FloatRect(rectInRootViewCoordinates.origin.x, rectInRootViewCoordinates.origin.y, rectInRootViewCoordinates.size.width, rectInRootViewCoordinates.size.height));
+}
+
+bool UIScriptController::isShowingMenu() const
+{
+    return TestController::singleton().mainWebView()->platformView().showingMenu;
+}
+
 void UIScriptController::platformSetDidEndScrollingCallback()
 {
     TestRunnerWKWebView *webView = TestController::singleton().mainWebView()->platformView();
_______________________________________________
webkit-changes mailing list
[email protected]
https://lists.webkit.org/mailman/listinfo/webkit-changes

Reply via email to