/* * 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 "Chrome.h" #import "ChromeClient.h" #import "Document.h" #import "Editor.h" #import "EventHandler.h" #import "FloatQuad.h" #import "FocusController.h" #import "Frame.h" #import "FrameSelection.h" #import "FrameView.h" #import "GapRects.h" #import "GraphicsContext.h" #import "GraphicsLayer.h" #import "GraphicsLayerCA.h" #import "Logging.h" #import "Page.h" #import "PageOverlayController.h" #import "Settings.h" #import "TextIterator.h" #import #import namespace WebCore { ServicesOverlayController::ServicesOverlayController(Page& page) : m_page(page) , m_determineActiveHighlightTimer(*this, &ServicesOverlayController::determineActiveHighlightTimerFired) , m_buildHighlightsTimer(*this, &ServicesOverlayController::buildPotentialHighlightsIfNeeded) { } ServicesOverlayController::~ServicesOverlayController() { for (auto& highlight : m_highlights) highlight.invalidate(); } void ServicesOverlayController::willMoveToPage(PageOverlay&, Page* page) { if (page) return; ASSERT(m_servicesOverlay); m_servicesOverlay = nullptr; } void ServicesOverlayController::didMoveToPage(PageOverlay&, Page*) { } static const uint8_t AlignmentNone = 0; static const uint8_t AlignmentLeft = 1 << 0; static const uint8_t AlignmentRight = 1 << 1; static void expandForGap(Vector& rects, uint8_t* alignments, const GapRects& gap) { if (!gap.left().isEmpty()) { LayoutUnit leftEdge = gap.left().x(); for (unsigned i = 0; i < rects.size(); ++i) { if (alignments[i] & AlignmentLeft) rects[i].shiftXEdgeTo(leftEdge); } } if (!gap.right().isEmpty()) { LayoutUnit rightEdge = gap.right().maxX(); for (unsigned i = 0; i < rects.size(); ++i) { if (alignments[i] & AlignmentRight) rects[i].shiftMaxXEdgeTo(rightEdge); } } } static inline void stitchRects(Vector& rects) { if (rects.size() <= 1) return; Vector newRects; // FIXME: Need to support vertical layout. // First stitch together all the rects on the first line of the selection. size_t indexFromStart = 0; LayoutUnit firstTop = rects[indexFromStart].y(); LayoutRect& currentRect = rects[indexFromStart]; while (indexFromStart < rects.size() && rects[indexFromStart].y() == firstTop) currentRect.unite(rects[indexFromStart++]); newRects.append(currentRect); if (indexFromStart == rects.size()) { // All the rects are on one line. There is nothing else to do. rects.swap(newRects); return; } // Next stitch together all the rects on the last line of the selection. size_t indexFromEnd = rects.size() - 1; LayoutUnit lastTop = rects[indexFromEnd].y(); LayoutRect lastRect = rects[indexFromEnd]; while (indexFromEnd >= indexFromStart && rects[indexFromEnd].y() == lastTop) lastRect.unite(rects[indexFromEnd--]); // indexFromStart is the index of the first rectangle on the second line. // indexFromEnd is the index of the last rectangle on the second to the last line. // if they are equal, there is one additional rectangle for the line in the middle. if (indexFromEnd == indexFromStart) newRects.append(rects[indexFromStart]); if (indexFromEnd <= indexFromStart) { // There are no more rects to stitch. Just append the last line. newRects.append(lastRect); rects.swap(newRects); return; } // Stitch together all the rects after the first line until the second to the last included. currentRect = rects[indexFromStart]; while (indexFromStart != indexFromEnd) currentRect.unite(rects[++indexFromStart]); newRects.append(currentRect); newRects.append(lastRect); rects.swap(newRects); } static void compactRectsWithGapRects(Vector& rects, const Vector& gapRects) { stitchRects(rects); // 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& rects, const Vector& gapRects, bool isTextOnly) { m_currentSelectionRects = rects; m_isTextOnly = isTextOnly; m_lastSelectionChangeTime = MonotonicTime::now(); 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()); invalidateHighlightsOfType(DataDetectorHighlight::Type::Selection); } void ServicesOverlayController::selectedTelephoneNumberRangesChanged() { LOG(Services, "ServicesOverlayController - Telephone number ranges changed\n"); invalidateHighlightsOfType(DataDetectorHighlight::Type::TelephoneNumber); } void ServicesOverlayController::invalidateHighlightsOfType(DataDetectorHighlight::Type type) { if (!m_page.settings().serviceControlsEnabled()) return; m_dirtyHighlightTypes.add(type); m_buildHighlightsTimer.startOneShot(0_s); } void ServicesOverlayController::buildPotentialHighlightsIfNeeded() { if (m_dirtyHighlightTypes.isEmpty()) return; if (m_dirtyHighlightTypes.contains(DataDetectorHighlight::Type::TelephoneNumber)) buildPhoneNumberHighlights(); if (m_dirtyHighlightTypes.contains(DataDetectorHighlight::Type::Selection)) buildSelectionHighlight(); m_dirtyHighlightTypes = { }; if (m_potentialHighlights.isEmpty()) { if (m_servicesOverlay) m_page.pageOverlayController().uninstallPageOverlay(*m_servicesOverlay, PageOverlay::FadeMode::DoNotFade); return; } if (telephoneNumberRangesForFocusedFrame().isEmpty() && !hasRelevantSelectionServices()) return; createOverlayIfNeeded(); bool mouseIsOverButton; determineActiveHighlight(mouseIsOverButton); } bool ServicesOverlayController::mouseIsOverHighlight(DataDetectorHighlight& highlight, bool& mouseIsOverButton) const { if (!PAL::isDataDetectorsFrameworkAvailable()) return false; Boolean onButton; bool hovered = PAL::softLink_DataDetectors_DDHighlightPointIsOnHighlight(highlight.highlight(), (CGPoint)m_mousePosition, &onButton); mouseIsOverButton = onButton; return hovered; } Seconds ServicesOverlayController::remainingTimeUntilHighlightShouldBeShown(DataDetectorHighlight* highlight) const { if (!highlight) return 0_s; Seconds minimumTimeUntilHighlightShouldBeShown = 200_ms; if (CheckedRef(m_page.focusController())->focusedOrMainFrame().selection().selection().isContentEditable()) minimumTimeUntilHighlightShouldBeShown = 1_s; bool mousePressed = mainFrame().eventHandler().mousePressed(); // Highlight hysteresis is only for selection services, because telephone number highlights are already much more stable // by virtue of being expanded to include the entire telephone number. However, we will still avoid highlighting // telephone numbers while the mouse is down. if (highlight->type() == DataDetectorHighlight::Type::TelephoneNumber) return mousePressed ? minimumTimeUntilHighlightShouldBeShown : 0_s; MonotonicTime now = MonotonicTime::now(); Seconds timeSinceLastSelectionChange = now - m_lastSelectionChangeTime; Seconds timeSinceHighlightBecameActive = now - m_nextActiveHighlightChangeTime; Seconds timeSinceLastMouseUp = mousePressed ? 0_s : now - m_lastMouseUpTime; return minimumTimeUntilHighlightShouldBeShown - std::min(std::min(timeSinceLastSelectionChange, timeSinceHighlightBecameActive), timeSinceLastMouseUp); } void ServicesOverlayController::determineActiveHighlightTimerFired() { bool mouseIsOverButton; determineActiveHighlight(mouseIsOverButton); } void ServicesOverlayController::drawRect(PageOverlay&, GraphicsContext&, const IntRect&) { } void ServicesOverlayController::clearActiveHighlight() { if (!m_activeHighlight) return; if (m_currentMouseDownOnButtonHighlight == m_activeHighlight) m_currentMouseDownOnButtonHighlight = nullptr; m_activeHighlight = nullptr; } void ServicesOverlayController::removeAllPotentialHighlightsOfType(DataDetectorHighlight::Type type) { Vector> highlightsToRemove; for (auto& highlight : m_potentialHighlights) { if (highlight->type() == type) highlightsToRemove.append(highlight); } while (!highlightsToRemove.isEmpty()) m_potentialHighlights.remove(highlightsToRemove.takeLast()); } void ServicesOverlayController::buildPhoneNumberHighlights() { Vector phoneNumberRanges; for (Frame* frame = &mainFrame(); frame; frame = frame->tree().traverseNext()) phoneNumberRanges.appendVector(frame->editor().detectedTelephoneNumberRanges()); if (phoneNumberRanges.isEmpty()) { removeAllPotentialHighlightsOfType(DataDetectorHighlight::Type::TelephoneNumber); return; } if (!PAL::isDataDetectorsFrameworkAvailable()) return; HashSet> newPotentialHighlights; FrameView& mainFrameView = *mainFrame().view(); for (auto& range : phoneNumberRanges) { // FIXME: This makes a big rect if the range extends from the end of one line to the start of the next. Handle that case better? auto rect = enclosingIntRect(unitedBoundingBoxes(RenderObject::absoluteTextQuads(range))); // 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. auto* viewForRange = range.start.document().view(); if (!viewForRange) continue; rect.setLocation(mainFrameView.windowToContents(viewForRange->contentsToWindow(rect.location()))); CGRect cgRect = rect; #if HAVE(DD_HIGHLIGHT_CREATE_WITH_SCALE) auto ddHighlight = adoptCF(PAL::softLink_DataDetectors_DDHighlightCreateWithRectsInVisibleRectWithStyleScaleAndDirection(nullptr, &cgRect, 1, mainFrameView.visibleContentRect(), static_cast(DDHighlightStyleBubbleStandard) | static_cast(DDHighlightStyleStandardIconArrow), YES, NSWritingDirectionNatural, NO, YES, 0)); #else auto ddHighlight = adoptCF(PAL::softLink_DataDetectors_DDHighlightCreateWithRectsInVisibleRectWithStyleAndDirection(nullptr, &cgRect, 1, mainFrameView.visibleContentRect(), static_cast(DDHighlightStyleBubbleStandard) | static_cast(DDHighlightStyleStandardIconArrow), YES, NSWritingDirectionNatural, NO, YES)); #endif auto highlight = DataDetectorHighlight::createForTelephoneNumber(m_page, *this, WTFMove(ddHighlight), WTFMove(range)); m_highlights.add(highlight.get()); newPotentialHighlights.add(WTFMove(highlight)); } replaceHighlightsOfTypePreservingEquivalentHighlights(newPotentialHighlights, DataDetectorHighlight::Type::TelephoneNumber); } void ServicesOverlayController::buildSelectionHighlight() { if (m_currentSelectionRects.isEmpty()) { removeAllPotentialHighlightsOfType(DataDetectorHighlight::Type::Selection); return; } if (!PAL::isDataDetectorsFrameworkAvailable()) return; HashSet> newPotentialHighlights; if (auto selectionRange = m_page.selection().firstRange()) { FrameView* mainFrameView = mainFrame().view(); if (!mainFrameView) return; RefPtr viewForRange = selectionRange->start.document().view(); if (!viewForRange) return; Vector cgRects; cgRects.reserveInitialCapacity(m_currentSelectionRects.size()); for (auto& rect : m_currentSelectionRects) { IntRect currentRect = snappedIntRect(rect); currentRect.setLocation(mainFrameView->windowToContents(viewForRange->contentsToWindow(currentRect.location()))); cgRects.uncheckedAppend(currentRect); } if (!cgRects.isEmpty()) { CGRect visibleRect = mainFrameView->visibleContentRect(); #if HAVE(DD_HIGHLIGHT_CREATE_WITH_SCALE) auto ddHighlight = adoptCF(PAL::softLink_DataDetectors_DDHighlightCreateWithRectsInVisibleRectWithStyleScaleAndDirection(nullptr, cgRects.begin(), cgRects.size(), visibleRect, static_cast(DDHighlightStyleBubbleNone) | static_cast(DDHighlightStyleStandardIconArrow) | static_cast(DDHighlightStyleButtonShowAlways), YES, NSWritingDirectionNatural, NO, YES, 0)); #else auto ddHighlight = adoptCF(PAL::softLink_DataDetectors_DDHighlightCreateWithRectsInVisibleRectWithStyleAndDirection(nullptr, cgRects.begin(), cgRects.size(), visibleRect, static_cast(DDHighlightStyleBubbleNone) | static_cast(DDHighlightStyleStandardIconArrow) | static_cast(DDHighlightStyleButtonShowAlways), YES, NSWritingDirectionNatural, NO, YES)); #endif auto highlight = DataDetectorHighlight::createForSelection(m_page, *this, WTFMove(ddHighlight), WTFMove(*selectionRange)); m_highlights.add(highlight.get()); newPotentialHighlights.add(WTFMove(highlight)); } } replaceHighlightsOfTypePreservingEquivalentHighlights(newPotentialHighlights, DataDetectorHighlight::Type::Selection); } void ServicesOverlayController::replaceHighlightsOfTypePreservingEquivalentHighlights(HashSet>& newPotentialHighlights, DataDetectorHighlight::Type type) { // If any old Highlights are equivalent (by Range) to a new Highlight, reuse the old // one so that any metadata is retained. HashSet> reusedPotentialHighlights; for (auto& oldHighlight : m_potentialHighlights) { if (oldHighlight->type() != type) continue; for (auto& newHighlight : newPotentialHighlights) { if (areEquivalent(oldHighlight.get(), newHighlight.get())) { oldHighlight->setHighlight(newHighlight->highlight()); reusedPotentialHighlights.add(oldHighlight); newPotentialHighlights.remove(newHighlight); break; } } } removeAllPotentialHighlightsOfType(type); m_potentialHighlights.add(newPotentialHighlights.begin(), newPotentialHighlights.end()); m_potentialHighlights.add(reusedPotentialHighlights.begin(), reusedPotentialHighlights.end()); } bool ServicesOverlayController::hasRelevantSelectionServices() { return m_page.chrome().client().hasRelevantSelectionServices(m_isTextOnly); } void ServicesOverlayController::createOverlayIfNeeded() { if (m_servicesOverlay) return; if (!m_page.settings().serviceControlsEnabled()) return; auto overlay = PageOverlay::create(*this, PageOverlay::OverlayType::Document); m_servicesOverlay = overlay.ptr(); m_page.pageOverlayController().installPageOverlay(WTFMove(overlay), PageOverlay::FadeMode::DoNotFade); } Vector ServicesOverlayController::telephoneNumberRangesForFocusedFrame() { return CheckedRef(m_page.focusController())->focusedOrMainFrame().editor().detectedTelephoneNumberRanges(); } DataDetectorHighlight* ServicesOverlayController::findTelephoneNumberHighlightContainingSelectionHighlight(DataDetectorHighlight& selectionHighlight) { if (selectionHighlight.type() != DataDetectorHighlight::Type::Selection) return nullptr; auto selectionRange = m_page.selection().toNormalizedRange(); if (!selectionRange) return nullptr; for (auto& highlight : m_potentialHighlights) { if (highlight->type() == DataDetectorHighlight::Type::TelephoneNumber && contains(highlight->range(), *selectionRange)) return highlight.get(); } return nullptr; } void ServicesOverlayController::determineActiveHighlight(bool& mouseIsOverActiveHighlightButton) { buildPotentialHighlightsIfNeeded(); mouseIsOverActiveHighlightButton = false; RefPtr newActiveHighlight; for (auto& highlight : m_potentialHighlights) { if (highlight->type() == DataDetectorHighlight::Type::Selection) { // If we've already found a new active highlight, and it's // a telephone number highlight, prefer that over this selection highlight. if (newActiveHighlight && newActiveHighlight->type() == DataDetectorHighlight::Type::TelephoneNumber) continue; // If this highlight has no compatible services, it can't be active. if (!hasRelevantSelectionServices()) continue; } // If this highlight isn't hovered, it can't be active. bool mouseIsOverButton; if (!mouseIsOverHighlight(*highlight, mouseIsOverButton)) continue; newActiveHighlight = highlight; mouseIsOverActiveHighlightButton = mouseIsOverButton; } // If our new active highlight is a selection highlight that is completely contained // by one of the phone number highlights, we'll make the phone number highlight active even if it's not hovered. if (newActiveHighlight && newActiveHighlight->type() == DataDetectorHighlight::Type::Selection) { if (DataDetectorHighlight* containedTelephoneNumberHighlight = findTelephoneNumberHighlightContainingSelectionHighlight(*newActiveHighlight)) { newActiveHighlight = containedTelephoneNumberHighlight; // We will always initially choose the telephone number highlight over the selection highlight if the // mouse is over the telephone number highlight's button, so we know that it's not hovered if we got here. mouseIsOverActiveHighlightButton = false; } } if (!areEquivalent(m_activeHighlight.get(), newActiveHighlight.get())) { // When transitioning to a new highlight, we might end up in determineActiveHighlight multiple times // before the new highlight actually becomes active. Keep track of the last next-but-not-yet-active // highlight, and only reset the active highlight hysteresis when that changes. if (m_nextActiveHighlight != newActiveHighlight) { m_nextActiveHighlight = newActiveHighlight; m_nextActiveHighlightChangeTime = MonotonicTime::now(); } m_currentMouseDownOnButtonHighlight = nullptr; if (m_activeHighlight) { m_activeHighlight->fadeOut(); m_activeHighlight = nullptr; } auto remainingTimeUntilHighlightShouldBeShown = this->remainingTimeUntilHighlightShouldBeShown(newActiveHighlight.get()); if (remainingTimeUntilHighlightShouldBeShown > 0_s) { m_determineActiveHighlightTimer.startOneShot(remainingTimeUntilHighlightShouldBeShown); return; } m_activeHighlight = WTFMove(m_nextActiveHighlight); if (m_activeHighlight) { Ref highlightLayer = m_activeHighlight->layer(); m_servicesOverlay->layer().addChild(WTFMove(highlightLayer)); m_activeHighlight->fadeIn(); } } } bool ServicesOverlayController::mouseEvent(PageOverlay&, const PlatformMouseEvent& event) { m_mousePosition = mainFrame().view()->windowToContents(event.position()); bool mouseIsOverActiveHighlightButton = false; determineActiveHighlight(mouseIsOverActiveHighlightButton); // Cancel the potential click if any button other than the left button changes state, and ignore the event. if (event.button() != MouseButton::LeftButton) { m_currentMouseDownOnButtonHighlight = nullptr; return false; } // If the mouse lifted while still over the highlight button that it went down on, then that is a click. if (event.type() == PlatformEvent::MouseReleased) { auto mouseDownHighlight = m_currentMouseDownOnButtonHighlight.copyRef(); m_currentMouseDownOnButtonHighlight = nullptr; m_lastMouseUpTime = MonotonicTime::now(); if (mouseIsOverActiveHighlightButton && mouseDownHighlight) { handleClick(m_mousePosition, *mouseDownHighlight); return true; } return false; } // If the mouse moved outside of the button tracking a potential click, stop tracking the click. if (event.type() == PlatformEvent::MouseMoved) { if (m_currentMouseDownOnButtonHighlight && mouseIsOverActiveHighlightButton) return true; m_currentMouseDownOnButtonHighlight = nullptr; return false; } // If the mouse went down over the active highlight's button, track this as a potential click. if (event.type() == PlatformEvent::MousePressed) { if (m_activeHighlight && mouseIsOverActiveHighlightButton) { m_currentMouseDownOnButtonHighlight = m_activeHighlight; return true; } return false; } return false; } void ServicesOverlayController::didScrollFrame(PageOverlay&, Frame& frame) { if (frame.isMainFrame()) return; invalidateHighlightsOfType(DataDetectorHighlight::Type::TelephoneNumber); invalidateHighlightsOfType(DataDetectorHighlight::Type::Selection); buildPotentialHighlightsIfNeeded(); bool mouseIsOverActiveHighlightButton; determineActiveHighlight(mouseIsOverActiveHighlightButton); } void ServicesOverlayController::handleClick(const IntPoint& clickPoint, DataDetectorHighlight& highlight) { FrameView* frameView = mainFrame().view(); if (!frameView) return; IntPoint windowPoint = frameView->contentsToWindow(clickPoint); if (highlight.type() == DataDetectorHighlight::Type::Selection) { auto selectedTelephoneNumbers = telephoneNumberRangesForFocusedFrame().map([](auto& range) { return plainText(range); }); m_page.chrome().client().handleSelectionServiceClick(CheckedRef(m_page.focusController())->focusedOrMainFrame().selection(), WTFMove(selectedTelephoneNumbers), windowPoint); } else if (highlight.type() == DataDetectorHighlight::Type::TelephoneNumber) m_page.chrome().client().handleTelephoneNumberClick(plainText(highlight.range()), windowPoint, frameView->contentsToWindow(CheckedRef(m_page.focusController())->focusedOrMainFrame().editor().firstRectForRange(highlight.range()))); } Frame& ServicesOverlayController::mainFrame() const { return m_page.mainFrame(); } } // namespace WebKit #endif // (ENABLE(SERVICE_CONTROLS) || ENABLE(TELEPHONE_NUMBER_DETECTION)) && PLATFORM(MAC)