ServicesOverlayController.mm   [plain text]


/*
 * Copyright (C) 2014 Apple Inc. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
 * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
 * THE POSSIBILITY OF SUCH DAMAGE.
 */

#import "config.h"
#import "ServicesOverlayController.h"

#if ENABLE(SERVICE_CONTROLS) || ENABLE(TELEPHONE_NUMBER_DETECTION) && PLATFORM(MAC)

#import "Logging.h"
#import "WebPage.h"
#import "WebProcess.h"
#import <WebCore/Document.h>
#import <WebCore/FloatQuad.h>
#import <WebCore/FrameView.h>
#import <WebCore/GapRects.h>
#import <WebCore/GraphicsContext.h>
#import <WebCore/MainFrame.h>
#import <WebCore/SoftLinking.h>

#if __has_include(<DataDetectors/DDHighlightDrawing.h>)
#import <DataDetectors/DDHighlightDrawing.h>
#else
typedef void* DDHighlightRef;
#endif

#if __has_include(<DataDetectors/DDHighlightDrawing_Private.h>)
#import <DataDetectors/DDHighlightDrawing_Private.h>
#endif

typedef NSUInteger DDHighlightStyle;
static const DDHighlightStyle DDHighlightNoOutlineWithArrow = (1 << 16);
static const DDHighlightStyle DDHighlightOutlineWithArrow = (1 << 16) | 1;

SOFT_LINK_PRIVATE_FRAMEWORK_OPTIONAL(DataDetectors)
SOFT_LINK(DataDetectors, DDHighlightCreateWithRectsInVisibleRectWithStyleAndDirection, DDHighlightRef, (CFAllocatorRef allocator, CGRect* rects, CFIndex count, CGRect globalVisibleRect, DDHighlightStyle style, Boolean withArrow, NSWritingDirection writingDirection, Boolean endsWithEOL, Boolean flipped), (allocator, rects, count, globalVisibleRect, style, withArrow, writingDirection, endsWithEOL, flipped))
SOFT_LINK(DataDetectors, DDHighlightGetLayerWithContext, CGLayerRef, (DDHighlightRef highlight, CGContextRef context), (highlight, context))
SOFT_LINK(DataDetectors, DDHighlightGetBoundingRect, CGRect, (DDHighlightRef highlight), (highlight))
SOFT_LINK(DataDetectors, DDHighlightPointIsOnHighlight, Boolean, (DDHighlightRef highlight, CGPoint point, Boolean* onButton), (highlight, point, onButton))

using namespace WebCore;

namespace WebKit {

static IntRect textQuadsToBoundingRectForRange(Range& range)
{
    Vector<FloatQuad> textQuads;
    range.textQuads(textQuads);
    FloatRect boundingRect;
    for (auto& quad : textQuads)
        boundingRect.unite(quad.boundingBox());
    return enclosingIntRect(boundingRect);
}

ServicesOverlayController::ServicesOverlayController(WebPage& webPage)
    : m_webPage(&webPage)
    , m_servicesOverlay(nullptr)
{
}

ServicesOverlayController::~ServicesOverlayController()
{
}

void ServicesOverlayController::pageOverlayDestroyed(PageOverlay*)
{
    // Before the overlay is destroyed, it should have moved out of the WebPage,
    // at which point we already cleared our back pointer.
    ASSERT(!m_servicesOverlay);
}

void ServicesOverlayController::willMoveToWebPage(PageOverlay*, WebPage* webPage)
{
    if (webPage)
        return;

    ASSERT(m_servicesOverlay);
    m_servicesOverlay = nullptr;

    ASSERT(m_webPage);
    m_webPage = nullptr;
}

void ServicesOverlayController::didMoveToWebPage(PageOverlay*, WebPage*)
{
}

void ServicesOverlayController::createOverlayIfNeeded()
{
    if (m_servicesOverlay) {
        m_servicesOverlay->setNeedsDisplay();
        return;
    }

    if (m_currentTelephoneNumberRanges.isEmpty() && (!WebProcess::shared().hasSelectionServices() || m_currentSelectionRects.isEmpty()))
        return;

    RefPtr<PageOverlay> overlay = PageOverlay::create(this, PageOverlay::OverlayType::Document);
    m_servicesOverlay = overlay.get();
    m_webPage->installPageOverlay(overlay.release(), PageOverlay::FadeMode::Fade);
    m_servicesOverlay->setNeedsDisplay();
}

static const uint8_t AlignmentNone = 0;
static const uint8_t AlignmentLeft = 1 << 0;
static const uint8_t AlignmentRight = 1 << 1;

static void expandForGap(Vector<LayoutRect>& rects, uint8_t* alignments, const GapRects& gap)
{
    if (!gap.left().isEmpty()) {
        LayoutUnit leftEdge = gap.left().x();
        for (unsigned i = 0; i < 3; ++i) {
            if (alignments[i] & AlignmentLeft)
                rects[i].shiftXEdgeTo(leftEdge);
        }
    }

    if (!gap.right().isEmpty()) {
        LayoutUnit rightEdge = gap.right().maxX();
        for (unsigned i = 0; i < 3; ++i) {
            if (alignments[i] & AlignmentRight)
                rects[i].shiftMaxXEdgeTo(rightEdge);
        }
    }
}

static void compactRectsWithGapRects(Vector<LayoutRect>& rects, const Vector<GapRects>& gapRects)
{
    if (rects.isEmpty())
        return;

    // All of the middle rects - everything but the first and last - can be unioned together.
    if (rects.size() > 3) {
        LayoutRect united;
        for (unsigned i = 1; i < rects.size() - 1; ++i)
            united.unite(rects[i]);

        rects[1] = united;
        rects[2] = rects.last();
        rects.shrink(3);
    }

    // FIXME: The following alignments are correct for LTR text.
    // We should also account for RTL.
    uint8_t alignments[3];
    if (rects.size() == 1) {
        alignments[0] = AlignmentLeft | AlignmentRight;
        alignments[1] = AlignmentNone;
        alignments[2] = AlignmentNone;
    } else if (rects.size() == 2) {
        alignments[0] = AlignmentRight;
        alignments[1] = AlignmentLeft;
        alignments[2] = AlignmentNone;
    } else {
        alignments[0] = AlignmentRight;
        alignments[1] = AlignmentLeft | AlignmentRight;
        alignments[2] = AlignmentLeft;
    }

    // Account for each GapRects by extending the edge of certain LayoutRects to meet the gap.
    for (auto& gap : gapRects)
        expandForGap(rects, alignments, gap);

    // If we have 3 rects we might need one final GapRects to align the edges.
    if (rects.size() == 3) {
        LayoutRect left;
        LayoutRect right;
        for (unsigned i = 0; i < 3; ++i) {
            if (alignments[i] & AlignmentLeft) {
                if (left.isEmpty())
                    left = rects[i];
                else if (rects[i].x() < left.x())
                    left = rects[i];
            }
            if (alignments[i] & AlignmentRight) {
                if (right.isEmpty())
                    right = rects[i];
                else if ((rects[i].x() + rects[i].width()) > (right.x() + right.width()))
                    right = rects[i];
            }
        }

        if (!left.isEmpty() || !right.isEmpty()) {
            GapRects gap;
            gap.uniteLeft(left);
            gap.uniteRight(right);
            expandForGap(rects, alignments, gap);
        }
    }
}

void ServicesOverlayController::selectionRectsDidChange(const Vector<LayoutRect>& rects, const Vector<GapRects>& gapRects)
{
#if __MAC_OS_X_VERSION_MIN_REQUIRED > 1090
    clearSelectionHighlight();
    m_currentSelectionRects = rects;

    compactRectsWithGapRects(m_currentSelectionRects, gapRects);

    // DataDetectors needs these reversed in order to place the arrow in the right location.
    m_currentSelectionRects.reverse();

    LOG(Services, "ServicesOverlayController - Selection rects changed - Now have %lu\n", rects.size());

    createOverlayIfNeeded();
#else
    UNUSED_PARAM(rects);
#endif
}

void ServicesOverlayController::selectedTelephoneNumberRangesChanged(const Vector<RefPtr<Range>>& ranges)
{
#if PLATFORM(MAC) && __MAC_OS_X_VERSION_MIN_REQUIRED > 1090
    LOG(Services, "ServicesOverlayController - Telephone number ranges changed - Had %lu, now have %lu\n", m_currentTelephoneNumberRanges.size(), ranges.size());
    m_currentTelephoneNumberRanges = ranges;
    m_telephoneNumberHighlights.clear();
    m_telephoneNumberHighlights.resize(ranges.size());

    createOverlayIfNeeded();
#else
    UNUSED_PARAM(ranges);
#endif
}

void ServicesOverlayController::clearHighlightState()
{
    clearSelectionHighlight();
    clearHoveredTelephoneNumberHighlight();

    m_telephoneNumberHighlights.clear();
}

void ServicesOverlayController::drawRect(PageOverlay* overlay, WebCore::GraphicsContext& graphicsContext, const WebCore::IntRect& dirtyRect)
{
    if (m_currentSelectionRects.isEmpty() && m_currentTelephoneNumberRanges.isEmpty()) {
        clearHighlightState();
        return;
    }

    if (drawTelephoneNumberHighlightIfVisible(graphicsContext, dirtyRect))
        return;

    drawSelectionHighlight(graphicsContext, dirtyRect);
}

void ServicesOverlayController::drawSelectionHighlight(WebCore::GraphicsContext& graphicsContext, const WebCore::IntRect& dirtyRect)
{
    // It's possible to end up drawing the selection highlight before we've actually received the selection rects.
    // If that happens we'll end up here again once we have the rects.
    if (m_currentSelectionRects.isEmpty())
        return;

    // If there are no installed selection services and we have no phone numbers detected, then we have nothing to draw.
    if (!WebProcess::shared().hasSelectionServices() && m_currentTelephoneNumberRanges.isEmpty())
        return;

    if (!m_selectionHighlight)
        maybeCreateSelectionHighlight();

    if (m_selectionHighlight)
        drawHighlight(m_selectionHighlight.get(), graphicsContext);
}

bool ServicesOverlayController::drawTelephoneNumberHighlightIfVisible(WebCore::GraphicsContext& graphicsContext, const WebCore::IntRect& dirtyRect)
{
    // Make sure the hovered telephone number highlight is still hovered.
    if (m_hoveredTelephoneNumberData) {
        Boolean onButton;
        if (!DDHighlightPointIsOnHighlight(m_hoveredTelephoneNumberData->highlight.get(), (CGPoint)m_mousePosition, &onButton))
            clearHoveredTelephoneNumberHighlight();

        bool foundMatchingRange = false;

        // Make sure the hovered highlight still corresponds to a current telephone number range.
        for (auto& range : m_currentTelephoneNumberRanges) {
            if (areRangesEqual(range.get(), m_hoveredTelephoneNumberData->range.get())) {
                foundMatchingRange = true;
                break;
            }
        }

        if (!foundMatchingRange)
            clearHoveredTelephoneNumberHighlight();
    }

    // Found out which - if any - telephone number is hovered.
    if (!m_hoveredTelephoneNumberData) {
        Boolean onButton;
        establishHoveredTelephoneHighlight(onButton);
    }

    // If a telephone number is actually hovered, draw it.
    if (m_hoveredTelephoneNumberData) {
        drawHighlight(m_hoveredTelephoneNumberData->highlight.get(), graphicsContext);
        return true;
    }

    return false;
}

void ServicesOverlayController::drawHighlight(DDHighlightRef highlight, WebCore::GraphicsContext& graphicsContext)
{
    ASSERT(highlight);

    Boolean onButton;
    bool mouseIsOverHighlight = DDHighlightPointIsOnHighlight(highlight, (CGPoint)m_mousePosition, &onButton);

    if (!mouseIsOverHighlight) {
        LOG(Services, "ServicesOverlayController::drawHighlight - Mouse is not over highlight, so drawing nothing");
        return;
    }

    CGContextRef cgContext = graphicsContext.platformContext();
    
    CGLayerRef highlightLayer = DDHighlightGetLayerWithContext(highlight, cgContext);
    CGRect highlightBoundingRect = DDHighlightGetBoundingRect(highlight);
    
    GraphicsContextStateSaver stateSaver(graphicsContext);

    graphicsContext.translate(toFloatSize(highlightBoundingRect.origin));

    CGRect highlightDrawRect = highlightBoundingRect;
    highlightDrawRect.origin.x = 0;
    highlightDrawRect.origin.y = 0;
    
    CGContextDrawLayerInRect(cgContext, highlightDrawRect, highlightLayer);
}

void ServicesOverlayController::clearSelectionHighlight()
{
    if (!m_selectionHighlight)
        return;

    if (m_currentHoveredHighlight == m_selectionHighlight)
        m_currentHoveredHighlight = nullptr;
    if (m_currentMouseDownOnButtonHighlight == m_selectionHighlight)
        m_currentMouseDownOnButtonHighlight = nullptr;
    m_selectionHighlight = nullptr;
}

void ServicesOverlayController::clearHoveredTelephoneNumberHighlight()
{
    if (!m_hoveredTelephoneNumberData)
        return;

    if (m_currentHoveredHighlight == m_hoveredTelephoneNumberData->highlight)
        m_currentHoveredHighlight = nullptr;
    if (m_currentMouseDownOnButtonHighlight == m_hoveredTelephoneNumberData->highlight)
        m_currentMouseDownOnButtonHighlight = nullptr;
    m_hoveredTelephoneNumberData = nullptr;
}

void ServicesOverlayController::establishHoveredTelephoneHighlight(Boolean& onButton)
{
    ASSERT(m_currentTelephoneNumberRanges.size() == m_telephoneNumberHighlights.size());

    for (unsigned i = 0; i < m_currentTelephoneNumberRanges.size(); ++i) {
        if (!m_telephoneNumberHighlights[i]) {
            // FIXME: This will choke if the range wraps around the edge of the view.
            // What should we do in that case?
            IntRect rect = textQuadsToBoundingRectForRange(*m_currentTelephoneNumberRanges[i]);

            // Convert to the main document's coordinate space.
            // FIXME: It's a little crazy to call contentsToWindow and then windowToContents in order to get the right coordinate space.
            // We should consider adding conversion functions to ScrollView for contentsToDocument(). Right now, contentsToRootView() is
            // not equivalent to what we need when you have a topContentInset or a header banner.
            FrameView* viewForRange = m_currentTelephoneNumberRanges[i]->ownerDocument().view();
            if (!viewForRange)
                continue;
            FrameView& mainFrameView = *m_webPage->corePage()->mainFrame().view();
            rect.setLocation(mainFrameView.windowToContents(viewForRange->contentsToWindow(rect.location())));

            CGRect cgRect = rect;
            m_telephoneNumberHighlights[i] = adoptCF(DDHighlightCreateWithRectsInVisibleRectWithStyleAndDirection(nullptr, &cgRect, 1, viewForRange->boundsRect(), DDHighlightOutlineWithArrow, YES, NSWritingDirectionNatural, NO, YES));
        }

        if (!DDHighlightPointIsOnHighlight(m_telephoneNumberHighlights[i].get(), (CGPoint)m_mousePosition, &onButton))
            continue;

        if (!m_hoveredTelephoneNumberData || m_hoveredTelephoneNumberData->highlight != m_telephoneNumberHighlights[i])
            m_hoveredTelephoneNumberData = std::make_unique<TelephoneNumberData>(m_telephoneNumberHighlights[i], m_currentTelephoneNumberRanges[i]);

        m_servicesOverlay->setNeedsDisplay();
        return;
    }

    clearHoveredTelephoneNumberHighlight();
    onButton = false;
}

void ServicesOverlayController::maybeCreateSelectionHighlight()
{
    ASSERT(!m_selectionHighlight);
    ASSERT(m_servicesOverlay);

    Vector<CGRect> cgRects;
    cgRects.reserveCapacity(m_currentSelectionRects.size());

    for (auto& rect : m_currentSelectionRects)
        cgRects.append((CGRect)pixelSnappedIntRect(rect));

    if (!cgRects.isEmpty()) {
        CGRect bounds = m_webPage->corePage()->mainFrame().view()->boundsRect();
        m_selectionHighlight = adoptCF(DDHighlightCreateWithRectsInVisibleRectWithStyleAndDirection(nullptr, cgRects.begin(), cgRects.size(), bounds, DDHighlightNoOutlineWithArrow, YES, NSWritingDirectionNatural, NO, YES));

        m_servicesOverlay->setNeedsDisplay();
    }
}

bool ServicesOverlayController::mouseEvent(PageOverlay*, const WebMouseEvent& event)
{
    m_mousePosition = m_webPage->corePage()->mainFrame().view()->rootViewToContents(event.position());

    DDHighlightRef oldHoveredHighlight = m_currentHoveredHighlight.get();

    Boolean onButton = false;
    establishHoveredTelephoneHighlight(onButton);
    if (m_hoveredTelephoneNumberData) {
        ASSERT(m_hoveredTelephoneNumberData->highlight);
        m_currentHoveredHighlight = m_hoveredTelephoneNumberData->highlight;
    } else {
        if (!m_selectionHighlight)
            maybeCreateSelectionHighlight();

        if (m_selectionHighlight && DDHighlightPointIsOnHighlight(m_selectionHighlight.get(), (CGPoint)m_mousePosition, &onButton))
            m_currentHoveredHighlight = m_selectionHighlight;
        else
            m_currentHoveredHighlight = nullptr;
    }

    if (oldHoveredHighlight != m_currentHoveredHighlight)
        m_servicesOverlay->setNeedsDisplay();

    // If this event has nothing to do with the left button, it clears the current mouse down tracking and we're done processing it.
    if (event.button() != WebMouseEvent::LeftButton) {
        m_currentMouseDownOnButtonHighlight = nullptr;
        return false;
    }

    // Check and see if the mouse went up and we have a current mouse down highlight button.
    if (event.type() == WebEvent::MouseUp) {
        RetainPtr<DDHighlightRef> mouseDownHighlight = std::move(m_currentMouseDownOnButtonHighlight);

        // If the mouse lifted while still over the highlight button that it went down on, then that is a click.
        if (onButton && mouseDownHighlight) {
            handleClick(m_mousePosition, mouseDownHighlight.get());
            return true;
        }
        
        return false;
    }

    // Check and see if the mouse moved within the confines of the DD highlight button.
    if (event.type() == WebEvent::MouseMove) {
        // Moving with the mouse button down is okay as long as the mouse never leaves the highlight button.
        if (m_currentMouseDownOnButtonHighlight && onButton)
            return true;

        m_currentMouseDownOnButtonHighlight = nullptr;
        return false;
    }

    // Check and see if the mouse went down over a DD highlight button.
    if (event.type() == WebEvent::MouseDown) {
        if (m_currentHoveredHighlight && onButton) {
            m_currentMouseDownOnButtonHighlight = m_currentHoveredHighlight;
            m_servicesOverlay->setNeedsDisplay();
            return true;
        }

        return false;
    }
        
    return false;
}

void ServicesOverlayController::handleClick(const WebCore::IntPoint& clickPoint, DDHighlightRef highlight)
{
    ASSERT(highlight);

    FrameView* frameView = m_webPage->mainFrameView();
    if (!frameView)
        return;

    IntPoint windowPoint = frameView->contentsToWindow(clickPoint);

    if (highlight == m_selectionHighlight) {
        Vector<String> selectedTelephoneNumbers;
        selectedTelephoneNumbers.reserveCapacity(m_currentTelephoneNumberRanges.size());
        for (auto& range : m_currentTelephoneNumberRanges)
            selectedTelephoneNumbers.append(range->text());

        m_webPage->handleSelectionServiceClick(m_webPage->corePage()->mainFrame().selection(), selectedTelephoneNumbers, windowPoint);
    } else if (m_hoveredTelephoneNumberData && m_hoveredTelephoneNumberData->highlight == highlight)
        m_webPage->handleTelephoneNumberClick(m_hoveredTelephoneNumberData->range->text(), windowPoint);
    else
        ASSERT_NOT_REACHED();
}
    
} // namespace WebKit

#endif // #if ENABLE(SERVICE_CONTROLS) || ENABLE(TELEPHONE_NUMBER_DETECTION) && PLATFORM(MAC)