blob: 891b57c96453e004e48770d9c7fadb551dc855d6 [file] [log] [blame]
/*
* Copyright (C) 2019 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.
*/
#include "config.h"
#include "ContentChangeObserver.h"
#if PLATFORM(IOS_FAMILY)
#include "Chrome.h"
#include "ChromeClient.h"
#include "DOMTimer.h"
#include "Document.h"
#include "HTMLIFrameElement.h"
#include "HTMLImageElement.h"
#include "Logging.h"
#include "NodeRenderStyle.h"
#include "Page.h"
#include "RenderDescendantIterator.h"
#include "Settings.h"
namespace WebCore {
static const Seconds maximumDelayForTimers { 400_ms };
static const Seconds maximumDelayForTransitions { 300_ms };
bool ContentChangeObserver::isVisuallyHidden(const Node& node)
{
if (!node.renderStyle())
return true;
auto& style = *node.renderStyle();
if (style.display() == DisplayType::None)
return true;
if (style.visibility() == Visibility::Hidden)
return true;
if (!style.opacity())
return true;
auto width = style.logicalWidth();
auto height = style.logicalHeight();
if ((width.isFixed() && !width.value()) || (height.isFixed() && !height.value()))
return true;
auto top = style.logicalTop();
auto left = style.logicalLeft();
// FIXME: This is trying to check if the element is outside of the viewport. This is incorrect for many reasons.
if (left.isFixed() && width.isFixed() && -left.value() >= width.value())
return true;
if (top.isFixed() && height.isFixed() && -top.value() >= height.value())
return true;
// It's a common technique used to position content offscreen.
if (style.hasOutOfFlowPosition() && left.isFixed() && left.value() <= -999)
return true;
// FIXME: Check for other cases like zero height with overflow hidden.
auto maxHeight = style.maxHeight();
if (maxHeight.isFixed() && !maxHeight.value())
return true;
// Special case opacity, because a descendant with non-zero opacity should still be considered hidden when one of its ancetors has opacity: 0;
// YouTube.com has this setup with the bottom control bar.
constexpr static unsigned numberOfAncestorsToCheckForOpacity = 4;
unsigned i = 0;
for (auto* parent = node.parentNode(); parent && i < numberOfAncestorsToCheckForOpacity; parent = parent->parentNode(), ++i) {
if (!parent->renderStyle() || !parent->renderStyle()->opacity())
return true;
}
return false;
}
bool ContentChangeObserver::isConsideredVisible(const Node& node)
{
if (isVisuallyHidden(node))
return false;
auto& style = *node.renderStyle();
auto width = style.logicalWidth();
// 1px width or height content is not considered visible.
if (width.isFixed() && width.value() <= 1)
return false;
auto height = style.logicalHeight();
if (height.isFixed() && height.value() <= 1)
return false;
return true;
}
enum class ElementHadRenderer { No, Yes };
static bool isConsideredClickable(const Element& newlyVisibleElement, ElementHadRenderer hadRenderer)
{
auto& element = const_cast<Element&>(newlyVisibleElement);
if (element.isInUserAgentShadowTree())
return false;
if (is<HTMLIFrameElement>(element))
return true;
if (is<HTMLImageElement>(element)) {
// This is required to avoid HTMLImageElement's touch callout override logic. See rdar://problem/48937767.
return element.Element::willRespondToMouseClickEvents();
}
auto willRespondToMouseClickEvents = element.willRespondToMouseClickEvents();
if (hadRenderer == ElementHadRenderer::No || willRespondToMouseClickEvents)
return willRespondToMouseClickEvents;
// In case when the visible content already had renderers it's not sufficient to check the "newly visible" element only since it might just be the container for the clickable content.
for (auto& descendant : descendantsOfType<RenderElement>(*element.renderer())) {
if (!descendant.element())
continue;
if (descendant.element()->willRespondToMouseClickEvents())
return true;
}
return false;
}
ContentChangeObserver::ContentChangeObserver(Document& document)
: m_document(document)
, m_contentObservationTimer([this] { completeDurationBasedContentObservation(); })
{
}
static void willNotProceedWithClick(Frame& mainFrame)
{
for (auto* frame = &mainFrame; frame; frame = frame->tree().traverseNext()) {
if (auto* document = frame->document())
document->contentChangeObserver().willNotProceedWithClick();
}
}
void ContentChangeObserver::didCancelPotentialTap(Frame& mainFrame)
{
LOG(ContentObservation, "didCancelPotentialTap: cancel ongoing content change observing.");
WebCore::willNotProceedWithClick(mainFrame);
}
void ContentChangeObserver::didRecognizeLongPress(Frame& mainFrame)
{
LOG(ContentObservation, "didRecognizeLongPress: cancel ongoing content change observing.");
WebCore::willNotProceedWithClick(mainFrame);
}
void ContentChangeObserver::didPreventDefaultForEvent(Frame& mainFrame)
{
LOG(ContentObservation, "didPreventDefaultForEvent: cancel ongoing content change observing.");
WebCore::willNotProceedWithClick(mainFrame);
}
void ContentChangeObserver::startContentObservationForDuration(Seconds duration)
{
if (!m_document.settings().contentChangeObserverEnabled())
return;
ASSERT(!hasVisibleChangeState());
LOG_WITH_STREAM(ContentObservation, stream << "startContentObservationForDuration: start observing the content for " << duration.milliseconds() << "ms");
adjustObservedState(Event::StartedFixedObservationTimeWindow);
m_contentObservationTimer.startOneShot(duration);
}
void ContentChangeObserver::completeDurationBasedContentObservation()
{
LOG_WITH_STREAM(ContentObservation, stream << "completeDurationBasedContentObservation: complete duration based content observing ");
adjustObservedState(Event::EndedFixedObservationTimeWindow);
}
void ContentChangeObserver::didAddTransition(const Element& element, const Animation& transition)
{
if (!m_document.settings().contentChangeObserverEnabled())
return;
if (hasVisibleChangeState())
return;
if (!isObservingTransitions())
return;
if (!transition.isDurationSet() || !transition.isPropertySet())
return;
if (!isObservedPropertyForTransition(transition.property()))
return;
auto transitionEnd = Seconds { transition.duration() + std::max<double>(0, transition.isDelaySet() ? transition.delay() : 0) };
if (transitionEnd > maximumDelayForTransitions)
return;
if (!isVisuallyHidden(element))
return;
// In case of multiple transitions, the first tranistion wins (and it has to produce a visible content change in order to show up as hover).
if (m_elementsWithTransition.contains(&element))
return;
LOG_WITH_STREAM(ContentObservation, stream << "didAddTransition: transition created on " << &element << " (" << transitionEnd.milliseconds() << "ms).");
m_elementsWithTransition.add(&element);
adjustObservedState(Event::AddedTransition);
}
void ContentChangeObserver::didFinishTransition(const Element& element, CSSPropertyID propertyID)
{
if (!isObservedPropertyForTransition(propertyID))
return;
if (!m_elementsWithTransition.take(&element))
return;
LOG_WITH_STREAM(ContentObservation, stream << "didFinishTransition: transition finished (" << &element << ").");
// isConsideredClickable may trigger style update through Node::computeEditability. Let's adjust the state in the next runloop.
callOnMainThread([weakThis = makeWeakPtr(*this), targetElement = makeWeakPtr(element)] {
if (!weakThis || !targetElement)
return;
if (isVisuallyHidden(*targetElement)) {
weakThis->adjustObservedState(Event::EndedTransitionButFinalStyleIsNotDefiniteYet);
return;
}
weakThis->adjustObservedState(isConsideredClickable(*targetElement, ElementHadRenderer::Yes) ? Event::CompletedTransitionWithClickableContent : Event::CompletedTransitionWithoutClickableContent);
});
}
void ContentChangeObserver::didRemoveTransition(const Element& element, CSSPropertyID propertyID)
{
if (!isObservedPropertyForTransition(propertyID))
return;
if (!m_elementsWithTransition.take(&element))
return;
LOG_WITH_STREAM(ContentObservation, stream << "didRemoveTransition: transition got interrupted (" << &element << ").");
adjustObservedState(Event::CanceledTransition);
}
void ContentChangeObserver::didInstallDOMTimer(const DOMTimer& timer, Seconds timeout, bool singleShot)
{
if (!m_document.settings().contentChangeObserverEnabled())
return;
if (m_document.activeDOMObjectsAreSuspended())
return;
if (timeout > maximumDelayForTimers || !singleShot)
return;
if (!isObservingDOMTimerScheduling())
return;
if (hasVisibleChangeState())
return;
LOG_WITH_STREAM(ContentObservation, stream << "didInstallDOMTimer: register this timer: (" << &timer << ") and observe when it fires.");
registerDOMTimer(timer);
adjustObservedState(Event::InstalledDOMTimer);
}
void ContentChangeObserver::didRemoveDOMTimer(const DOMTimer& timer)
{
if (!containsObservedDOMTimer(timer))
return;
LOG_WITH_STREAM(ContentObservation, stream << "removeDOMTimer: remove registered timer (" << &timer << ")");
unregisterDOMTimer(timer);
adjustObservedState(Event::RemovedDOMTimer);
}
void ContentChangeObserver::willNotProceedWithClick()
{
LOG(ContentObservation, "willNotProceedWithClick: click will not happen.");
adjustObservedState(Event::WillNotProceedWithClick);
}
void ContentChangeObserver::domTimerExecuteDidStart(const DOMTimer& timer)
{
if (!containsObservedDOMTimer(timer))
return;
LOG_WITH_STREAM(ContentObservation, stream << "startObservingDOMTimerExecute: start observing (" << &timer << ") timer callback.");
m_observedDomTimerIsBeingExecuted = true;
adjustObservedState(Event::StartedDOMTimerExecution);
}
void ContentChangeObserver::domTimerExecuteDidFinish(const DOMTimer& timer)
{
if (!m_observedDomTimerIsBeingExecuted)
return;
LOG_WITH_STREAM(ContentObservation, stream << "stopObservingDOMTimerExecute: stop observing (" << &timer << ") timer callback.");
m_observedDomTimerIsBeingExecuted = false;
unregisterDOMTimer(timer);
adjustObservedState(Event::EndedDOMTimerExecution);
}
void ContentChangeObserver::styleRecalcDidStart()
{
if (!isWaitingForStyleRecalc())
return;
LOG(ContentObservation, "startObservingStyleRecalc: start observing style recalc.");
m_isInObservedStyleRecalc = true;
adjustObservedState(Event::StartedStyleRecalc);
}
void ContentChangeObserver::styleRecalcDidFinish()
{
if (!m_isInObservedStyleRecalc)
return;
LOG(ContentObservation, "stopObservingStyleRecalc: stop observing style recalc");
m_isInObservedStyleRecalc = false;
adjustObservedState(Event::EndedStyleRecalc);
}
void ContentChangeObserver::renderTreeUpdateDidStart()
{
if (!m_document.settings().contentChangeObserverEnabled())
return;
if (!isObservingContentChanges())
return;
LOG(ContentObservation, "renderTreeUpdateDidStart: RenderTree update started");
m_isInObservedRenderTreeUpdate = true;
m_elementsWithDestroyedVisibleRenderer.clear();
}
void ContentChangeObserver::renderTreeUpdateDidFinish()
{
if (!m_isInObservedRenderTreeUpdate)
return;
LOG(ContentObservation, "renderTreeUpdateDidStart: RenderTree update finished");
m_isInObservedRenderTreeUpdate = false;
m_elementsWithDestroyedVisibleRenderer.clear();
}
void ContentChangeObserver::stopObservingPendingActivities()
{
setShouldObserveNextStyleRecalc(false);
setShouldObserveDOMTimerScheduling(false);
setShouldObserveTransitions(false);
clearObservedDOMTimers();
clearObservedTransitions();
}
void ContentChangeObserver::reset()
{
stopObservingPendingActivities();
setHasNoChangeState();
setIsBetweenTouchEndAndMouseMoved(false);
m_touchEventIsBeingDispatched = false;
m_isInObservedStyleRecalc = false;
m_isInObservedRenderTreeUpdate = false;
m_observedDomTimerIsBeingExecuted = false;
m_mouseMovedEventIsBeingDispatched = false;
m_contentObservationTimer.stop();
m_elementsWithDestroyedVisibleRenderer.clear();
resetHiddenTouchTarget();
}
void ContentChangeObserver::didSuspendActiveDOMObjects()
{
LOG(ContentObservation, "didSuspendActiveDOMObjects");
reset();
}
void ContentChangeObserver::willDetachPage()
{
LOG(ContentObservation, "willDetachPage");
reset();
}
void ContentChangeObserver::willDestroyRenderer(const Element& element)
{
if (!m_document.settings().contentChangeObserverEnabled())
return;
if (!m_isInObservedRenderTreeUpdate)
return;
if (hasVisibleChangeState())
return;
LOG_WITH_STREAM(ContentObservation, stream << "willDestroyRenderer element: " << &element);
if (!isVisuallyHidden(element))
m_elementsWithDestroyedVisibleRenderer.add(&element);
}
void ContentChangeObserver::contentVisibilityDidChange()
{
LOG(ContentObservation, "contentVisibilityDidChange: visible content change did happen.");
adjustObservedState(Event::ContentVisibilityChanged);
}
void ContentChangeObserver::touchEventDidStart(PlatformEvent::Type eventType)
{
#if ENABLE(TOUCH_EVENTS)
if (!m_document.settings().contentChangeObserverEnabled())
return;
if (eventType != PlatformEvent::Type::TouchStart)
return;
LOG(ContentObservation, "touchEventDidStart: touch start event started.");
m_touchEventIsBeingDispatched = true;
adjustObservedState(Event::StartedTouchStartEventDispatching);
#else
UNUSED_PARAM(eventType);
#endif
}
void ContentChangeObserver::touchEventDidFinish()
{
#if ENABLE(TOUCH_EVENTS)
if (!m_touchEventIsBeingDispatched)
return;
ASSERT(m_document.settings().contentChangeObserverEnabled());
LOG(ContentObservation, "touchEventDidFinish: touch start event finished.");
m_touchEventIsBeingDispatched = false;
adjustObservedState(Event::EndedTouchStartEventDispatching);
#endif
}
void ContentChangeObserver::mouseMovedDidStart()
{
if (!m_document.settings().contentChangeObserverEnabled())
return;
LOG(ContentObservation, "mouseMovedDidStart: mouseMoved started.");
m_mouseMovedEventIsBeingDispatched = true;
adjustObservedState(Event::StartedMouseMovedEventDispatching);
}
void ContentChangeObserver::mouseMovedDidFinish()
{
if (!m_mouseMovedEventIsBeingDispatched)
return;
ASSERT(m_document.settings().contentChangeObserverEnabled());
LOG(ContentObservation, "mouseMovedDidFinish: mouseMoved finished.");
adjustObservedState(Event::EndedMouseMovedEventDispatching);
m_mouseMovedEventIsBeingDispatched = false;
}
void ContentChangeObserver::setShouldObserveNextStyleRecalc(bool shouldObserve)
{
if (shouldObserve)
LOG(ContentObservation, "Wait until next style recalc fires.");
m_isWaitingForStyleRecalc = shouldObserve;
}
bool ContentChangeObserver::hasDeterminateState() const
{
if (hasVisibleChangeState())
return true;
return observedContentChange() == WKContentNoChange && !hasPendingActivity();
}
void ContentChangeObserver::adjustObservedState(Event event)
{
auto resetToStartObserving = [&] {
setHasNoChangeState();
clearObservedDOMTimers();
clearObservedTransitions();
setIsBetweenTouchEndAndMouseMoved(false);
setShouldObserveNextStyleRecalc(false);
setShouldObserveDOMTimerScheduling(false);
setShouldObserveTransitions(false);
ASSERT(!m_isInObservedStyleRecalc);
ASSERT(!m_observedDomTimerIsBeingExecuted);
};
auto adjustStateAndNotifyContentChangeIfNeeded = [&] {
// Demote to "no change" when there's no pending activity anymore.
if (observedContentChange() == WKContentIndeterminateChange && !hasPendingActivity())
setHasNoChangeState();
// Do not notify the client unless we couldn't make the decision synchronously.
if (m_mouseMovedEventIsBeingDispatched) {
LOG(ContentObservation, "adjustStateAndNotifyContentChangeIfNeeded: in mouseMoved call. No need to notify the client.");
return;
}
if (isBetweenTouchEndAndMouseMoved()) {
LOG(ContentObservation, "adjustStateAndNotifyContentChangeIfNeeded: Not reached mouseMoved yet. No need to notify the client.");
return;
}
if (!hasDeterminateState()) {
LOG(ContentObservation, "adjustStateAndNotifyContentChangeIfNeeded: not in a determined state yet.");
return;
}
LOG_WITH_STREAM(ContentObservation, stream << "adjustStateAndNotifyContentChangeIfNeeded: sending observedContentChange ->" << observedContentChange());
ASSERT(m_document.page());
ASSERT(m_document.frame());
m_document.page()->chrome().client().observedContentChange(*m_document.frame());
};
switch (event) {
case Event::StartedTouchStartEventDispatching:
resetToStartObserving();
setShouldObserveDOMTimerScheduling(true);
setShouldObserveTransitions(true);
break;
case Event::EndedTouchStartEventDispatching:
setShouldObserveDOMTimerScheduling(false);
setShouldObserveTransitions(false);
setIsBetweenTouchEndAndMouseMoved(true);
break;
case Event::WillNotProceedWithClick:
reset();
break;
case Event::StartedMouseMovedEventDispatching:
ASSERT(!m_document.hasPendingStyleRecalc());
if (!isBetweenTouchEndAndMouseMoved())
resetToStartObserving();
setIsBetweenTouchEndAndMouseMoved(false);
setShouldObserveDOMTimerScheduling(!hasVisibleChangeState());
setShouldObserveTransitions(!hasVisibleChangeState());
break;
case Event::EndedMouseMovedEventDispatching:
setShouldObserveDOMTimerScheduling(false);
setShouldObserveTransitions(false);
break;
case Event::StartedStyleRecalc:
setShouldObserveNextStyleRecalc(false);
FALLTHROUGH;
case Event::StartedDOMTimerExecution:
ASSERT(isObservationTimeWindowActive() || observedContentChange() == WKContentIndeterminateChange);
break;
case Event::InstalledDOMTimer:
case Event::StartedFixedObservationTimeWindow:
case Event::AddedTransition:
ASSERT(!hasVisibleChangeState());
setHasIndeterminateState();
break;
case Event::EndedDOMTimerExecution:
setShouldObserveNextStyleRecalc(m_document.hasPendingStyleRecalc());
FALLTHROUGH;
case Event::EndedStyleRecalc:
case Event::RemovedDOMTimer:
case Event::CanceledTransition:
if (!isObservationTimeWindowActive())
adjustStateAndNotifyContentChangeIfNeeded();
break;
case Event::EndedTransitionButFinalStyleIsNotDefiniteYet:
// onAnimationEnd can be called while in the middle of resolving the document (synchronously) or
// asynchronously right before the style update is issued. It also means we don't know whether this animation ends up producing visible content yet.
if (m_document.inStyleRecalc()) {
// We need to start observing this style change synchronously.
m_isInObservedStyleRecalc = true;
} else
setShouldObserveNextStyleRecalc(true);
break;
case Event::CompletedTransitionWithClickableContent:
// Set visibility flag on and report visible change synchronously or asynchronously depending whether we are in the middle of style recalc.
contentVisibilityDidChange();
FALLTHROUGH;
case Event::CompletedTransitionWithoutClickableContent:
if (m_document.inStyleRecalc())
m_isInObservedStyleRecalc = true;
else if (!isObservationTimeWindowActive())
adjustStateAndNotifyContentChangeIfNeeded();
break;
case Event::EndedFixedObservationTimeWindow:
adjustStateAndNotifyContentChangeIfNeeded();
break;
case Event::ContentVisibilityChanged:
setHasVisibleChangeState();
// Stop pending activities. We don't need to observe them anymore.
stopObservingPendingActivities();
break;
}
}
bool ContentChangeObserver::shouldObserveVisibilityChangeForElement(const Element& element)
{
return isObservingContentChanges() && !hasVisibleChangeState() && !visibleRendererWasDestroyed(element);
}
ContentChangeObserver::StyleChangeScope::StyleChangeScope(Document& document, const Element& element)
: m_contentChangeObserver(document.contentChangeObserver())
, m_element(element)
, m_hadRenderer(element.renderer())
{
if (m_contentChangeObserver.shouldObserveVisibilityChangeForElement(element))
m_wasHidden = isVisuallyHidden(m_element);
}
ContentChangeObserver::StyleChangeScope::~StyleChangeScope()
{
auto changedFromHiddenToVisible = [&] {
return m_wasHidden && isConsideredVisible(m_element);
};
if (changedFromHiddenToVisible() && isConsideredClickable(m_element, m_hadRenderer ? ElementHadRenderer::Yes : ElementHadRenderer::No))
m_contentChangeObserver.contentVisibilityDidChange();
}
#if ENABLE(TOUCH_EVENTS)
ContentChangeObserver::TouchEventScope::TouchEventScope(Document& document, PlatformEvent::Type eventType)
: m_contentChangeObserver(document.contentChangeObserver())
{
m_contentChangeObserver.touchEventDidStart(eventType);
}
ContentChangeObserver::TouchEventScope::~TouchEventScope()
{
m_contentChangeObserver.touchEventDidFinish();
}
#endif
ContentChangeObserver::MouseMovedScope::MouseMovedScope(Document& document)
: m_contentChangeObserver(document.contentChangeObserver())
{
m_contentChangeObserver.mouseMovedDidStart();
}
ContentChangeObserver::MouseMovedScope::~MouseMovedScope()
{
m_contentChangeObserver.mouseMovedDidFinish();
m_contentChangeObserver.resetHiddenTouchTarget();
}
ContentChangeObserver::StyleRecalcScope::StyleRecalcScope(Document& document)
: m_contentChangeObserver(document.contentChangeObserver())
{
m_contentChangeObserver.styleRecalcDidStart();
}
ContentChangeObserver::StyleRecalcScope::~StyleRecalcScope()
{
m_contentChangeObserver.styleRecalcDidFinish();
}
ContentChangeObserver::DOMTimerScope::DOMTimerScope(Document* document, const DOMTimer& domTimer)
: m_contentChangeObserver(document ? &document->contentChangeObserver() : nullptr)
, m_domTimer(domTimer)
{
if (m_contentChangeObserver)
m_contentChangeObserver->domTimerExecuteDidStart(m_domTimer);
}
ContentChangeObserver::DOMTimerScope::~DOMTimerScope()
{
if (m_contentChangeObserver)
m_contentChangeObserver->domTimerExecuteDidFinish(m_domTimer);
}
ContentChangeObserver::RenderTreeUpdateScope::RenderTreeUpdateScope(Document& document)
: m_contentChangeObserver(document.contentChangeObserver())
{
m_contentChangeObserver.renderTreeUpdateDidStart();
}
ContentChangeObserver::RenderTreeUpdateScope::~RenderTreeUpdateScope()
{
m_contentChangeObserver.renderTreeUpdateDidFinish();
}
}
#endif // PLATFORM(IOS_FAMILY)