blob: daf43d79a97f31e8b4eda8f29961646422ed909a [file] [log] [blame]
/*
* Copyright (C) 2021-2022 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 "ModalContainerObserver.h"
#include "AccessibilityObject.h"
#include "Chrome.h"
#include "ChromeClient.h"
#include "Document.h"
#include "DocumentLoader.h"
#include "ElementIterator.h"
#include "EventHandler.h"
#include "EventLoop.h"
#include "EventNames.h"
#include "Frame.h"
#include "FrameView.h"
#include "HTMLAnchorElement.h"
#include "HTMLBodyElement.h"
#include "HTMLDocument.h"
#include "HTMLElement.h"
#include "HTMLImageElement.h"
#include "HTMLInputElement.h"
#include "HitTestResult.h"
#include "ModalContainerTypes.h"
#include "Page.h"
#include "RenderDescendantIterator.h"
#include "RenderText.h"
#include "RenderView.h"
#include "SimulatedClickOptions.h"
#include "Text.h"
#include <wtf/Noncopyable.h>
#include <wtf/RobinHoodHashSet.h>
#include <wtf/Scope.h>
#include <wtf/URL.h>
namespace WebCore {
static constexpr size_t maxLengthForClickableElementText = 100;
static constexpr double maxWidthForElementsThatLookClickable = 200;
static constexpr double maxHeightForElementsThatLookClickable = 100;
bool ModalContainerObserver::isNeededFor(const Document& document)
{
RefPtr topDocumentLoader = document.topDocument().loader();
if (!topDocumentLoader || topDocumentLoader->modalContainerObservationPolicy() == ModalContainerObservationPolicy::Disabled)
return false;
if (!document.topDocument().url().protocolIsInHTTPFamily())
return false;
if (document.inDesignMode() || !is<HTMLDocument>(document))
return false;
auto* frame = document.frame();
if (!frame)
return false;
auto* page = frame->page();
if (!page || page->isEditable())
return false;
if (RefPtr owner = document.ownerElement()) {
auto observer = owner->document().modalContainerObserverIfExists();
return observer && observer->m_frameOwnersAndContainersToSearchAgain.contains(*owner);
}
return true;
}
ModalContainerObserver::ModalContainerObserver()
: m_collectClickableElementsTimer(*this, &ModalContainerObserver::collectClickableElementsTimerFired)
{
}
ModalContainerObserver::~ModalContainerObserver() = default;
static bool matchesSearchTerm(const Text& textNode, const AtomString& searchTerm)
{
RefPtr parent = textNode.parentElementInComposedTree();
static NeverDestroyed tagNamesToSearch = [] {
static constexpr std::array tags {
&HTMLNames::aTag,
&HTMLNames::divTag,
&HTMLNames::pTag,
&HTMLNames::spanTag,
&HTMLNames::sectionTag,
&HTMLNames::bTag,
&HTMLNames::iTag,
&HTMLNames::uTag,
&HTMLNames::liTag,
&HTMLNames::h1Tag,
&HTMLNames::h2Tag,
&HTMLNames::h3Tag,
&HTMLNames::h4Tag,
&HTMLNames::h5Tag,
&HTMLNames::h6Tag,
};
MemoryCompactLookupOnlyRobinHoodHashSet<AtomString> set;
set.reserveInitialCapacity(std::size(tags));
for (auto& tag : tags)
set.add(tag->get().localName());
return set;
}();
if (!is<HTMLElement>(parent.get()) || !tagNamesToSearch.get().contains(downcast<HTMLElement>(*parent).localName()))
return false;
if (LIKELY(!textNode.data().containsIgnoringASCIICase(searchTerm)))
return false;
return true;
}
static AccessibilityRole accessibilityRole(const HTMLElement& element)
{
return AccessibilityObject::ariaRoleToWebCoreRole(element.attributeWithoutSynchronization(HTMLNames::roleAttr));
}
static bool isInsideNavigationElement(const Text& textNode)
{
for (auto& parent : ancestorsOfType<HTMLElement>(textNode)) {
if (parent.hasTagName(HTMLNames::navTag) || accessibilityRole(parent) == AccessibilityRole::LandmarkNavigation)
return true;
}
return false;
}
struct TextSearchResult {
bool foundMatch { false };
bool containsAnyText { false };
};
static TextSearchResult searchForMatch(RenderLayerModelObject& renderer, const AtomString& searchTerm)
{
TextSearchResult result;
for (auto& textRenderer : descendantsOfType<RenderText>(renderer)) {
result.containsAnyText = true;
if (RefPtr textNode = textRenderer.textNode(); textNode && matchesSearchTerm(*textNode, searchTerm)) {
result.foundMatch = !isInsideNavigationElement(*textNode);
return result;
}
}
return result;
}
void ModalContainerObserver::searchForModalContainerOnBehalfOfFrameOwnerIfNeeded(HTMLFrameOwnerElement& owner)
{
auto containerToSearchAgain = m_frameOwnersAndContainersToSearchAgain.take(owner);
if (!containerToSearchAgain)
return;
if (!m_elementsToIgnoreWhenSearching.remove(*containerToSearchAgain))
return;
if (RefPtr view = owner.document().view())
updateModalContainerIfNeeded(*view);
}
void ModalContainerObserver::updateModalContainerIfNeeded(const FrameView& view)
{
if (container())
return;
if (m_hasAttemptedToFulfillPolicy)
return;
if (RefPtr owner = view.frame().ownerElement()) {
if (auto parentObserver = owner->document().modalContainerObserverIfExists())
parentObserver->searchForModalContainerOnBehalfOfFrameOwnerIfNeeded(*owner);
return;
}
if (!view.frame().isMainFrame())
return;
if (!view.hasViewportConstrainedObjects())
return;
auto searchTerm = ([&]() -> AtomString {
if (UNLIKELY(!m_overrideSearchTermForTesting.isNull()))
return m_overrideSearchTermForTesting;
if (auto* page = view.frame().page())
return page->chrome().client().searchStringForModalContainerObserver();
return nullAtom();
})();
if (searchTerm.isNull())
return;
for (auto& renderer : *view.viewportConstrainedObjects()) {
if (renderer.isDocumentElementRenderer())
continue;
if (renderer.style().visibility() == Visibility::Hidden)
continue;
RefPtr element = renderer.element();
if (!element || is<HTMLBodyElement>(*element) || element->isDocumentNode())
continue;
if (m_elementsToIgnoreWhenSearching.contains(*element))
continue;
auto [foundMatch, containsAnyText] = searchForMatch(renderer, searchTerm);
if (containsAnyText)
m_elementsToIgnoreWhenSearching.add(*element);
if (foundMatch) {
setContainer(*element);
return;
}
for (auto& frameOwner : descendantsOfType<HTMLFrameOwnerElement>(*element)) {
RefPtr contentFrame = frameOwner.contentFrame();
if (!contentFrame)
continue;
auto renderView = contentFrame->contentRenderer();
if (!renderView)
continue;
if (searchForMatch(*renderView, searchTerm).foundMatch) {
setContainer(*element, &frameOwner);
return;
}
if (auto frameView = contentFrame->view(); frameView && !frameView->isVisuallyNonEmpty()) {
// If the subframe content has not become visually non-empty yet, search the subframe again later.
m_frameOwnersAndContainersToSearchAgain.add(frameOwner, *element);
}
}
}
}
void ModalContainerObserver::setContainer(Element& newContainer, HTMLFrameOwnerElement* frameOwner)
{
if (container())
container()->invalidateStyle();
if (m_userInteractionBlockingElement)
m_userInteractionBlockingElement->invalidateStyle();
m_userInteractionBlockingElement = { };
m_containerAndFrameOwnerForControls = { { newContainer }, { frameOwner } };
newContainer.invalidateStyle();
scheduleClickableElementCollection();
newContainer.document().eventLoop().queueTask(TaskSource::InternalAsyncTask, [weakContainer = WeakPtr { newContainer }]() mutable {
RefPtr container = weakContainer.get();
if (!container)
return;
auto observer = container->document().modalContainerObserverIfExists();
if (!observer || container != observer->container())
return;
observer->hideUserInteractionBlockingElementIfNeeded();
observer->makeBodyAndDocumentElementScrollableIfNeeded();
});
}
Element* ModalContainerObserver::container() const
{
return m_containerAndFrameOwnerForControls.first.get();
}
HTMLFrameOwnerElement* ModalContainerObserver::frameOwnerForControls() const
{
return m_containerAndFrameOwnerForControls.second.get();
}
static bool listensForUserActivation(const Element& element)
{
return element.hasEventListeners(eventNames().clickEvent) || element.hasEventListeners(eventNames().mousedownEvent) || element.hasEventListeners(eventNames().mouseupEvent)
|| element.hasEventListeners(eventNames().touchstartEvent) || element.hasEventListeners(eventNames().touchendEvent)
|| element.hasEventListeners(eventNames().pointerdownEvent) || element.hasEventListeners(eventNames().pointerupEvent);
}
enum class ContainerListensForUserActivation : bool { No, Yes };
static bool isClickableControl(const HTMLElement& element, ContainerListensForUserActivation containerListensForUserActivation)
{
if (element.isActuallyDisabled())
return false;
if (!element.renderer())
return false;
if (element.hasTagName(HTMLNames::buttonTag))
return true;
if (is<HTMLInputElement>(element) && downcast<HTMLInputElement>(element).isTextButton())
return true;
switch (accessibilityRole(element)) {
case AccessibilityRole::Button:
return true;
case AccessibilityRole::CheckBox:
case AccessibilityRole::Switch:
return false;
default:
break;
}
if (is<HTMLAnchorElement>(element)) {
// FIXME: We might need a more comprehensive policy here that attempts to click the link to detect navigation,
// but then immediately cancels the navigation.
auto href = downcast<HTMLAnchorElement>(element).href();
return equalIgnoringFragmentIdentifier(element.document().url(), href) || !href.protocolIsInHTTPFamily();
}
if (listensForUserActivation(element))
return true;
if (containerListensForUserActivation == ContainerListensForUserActivation::No)
return false;
auto rendererAndRect = element.boundingAbsoluteRectWithoutLayout();
if (!rendererAndRect)
return false;
auto [renderer, rect] = *rendererAndRect;
if (!renderer || rect.isEmpty())
return false;
// If the modal container itself has event listeners for user activation, continue looking for elements that look like
// clickable elements (e.g. small nodes with pointer-style cursor).
if (renderer->style().cursor() == CursorType::Pointer) {
if (rect.width() <= maxWidthForElementsThatLookClickable && rect.height() <= maxHeightForElementsThatLookClickable)
return true;
}
return false;
}
static void removeParentOrChildElements(Vector<Ref<HTMLElement>>& elements)
{
HashSet<Ref<HTMLElement>> elementsToRemove;
for (auto& outer : elements) {
if (elementsToRemove.contains(outer))
continue;
for (auto& inner : elements) {
if (elementsToRemove.contains(inner) || outer.ptr() == inner.ptr() || !outer->contains(inner))
continue;
if (accessibilityRole(outer) == AccessibilityRole::Button) {
elementsToRemove.add(inner);
continue;
}
if (accessibilityRole(inner) == AccessibilityRole::Button) {
elementsToRemove.add(outer);
continue;
}
if (outer->hasTagName(HTMLNames::divTag) || outer->hasTagName(HTMLNames::spanTag) || outer->hasTagName(HTMLNames::pTag) || outer->hasTagName(HTMLNames::sectionTag))
elementsToRemove.add(outer);
else
elementsToRemove.add(inner);
}
}
elements.removeAllMatching([&] (auto& control) {
return elementsToRemove.contains(control);
});
}
static void removeElementsWithEmptyBounds(Vector<Ref<HTMLElement>>& elements)
{
elements.removeAllMatching([&] (auto& element) {
return element->boundingClientRect().isEmpty();
});
}
static String textForControl(HTMLElement& control)
{
auto ariaLabel = control.attributeWithoutSynchronization(HTMLNames::aria_labelAttr);
if (!ariaLabel.isEmpty())
return ariaLabel;
if (is<HTMLInputElement>(control))
return downcast<HTMLInputElement>(control).value();
auto title = control.title();
if (!title.isEmpty())
return title;
if (is<HTMLImageElement>(control)) {
auto altText = downcast<HTMLImageElement>(control).altText();
if (!altText.isEmpty())
return altText;
}
return control.outerText();
}
void ModalContainerObserver::scheduleClickableElementCollection()
{
m_collectClickableElementsTimer.startOneShot(200_ms);
}
class ModalContainerPolicyDecisionScope {
WTF_MAKE_NONCOPYABLE(ModalContainerPolicyDecisionScope);
WTF_MAKE_FAST_ALLOCATED;
public:
ModalContainerPolicyDecisionScope(Document& document)
: m_document { document }
{
}
ModalContainerPolicyDecisionScope(ModalContainerPolicyDecisionScope&&) = default;
~ModalContainerPolicyDecisionScope()
{
if (m_continueHidingModalContainerAfterScope || !m_document)
return;
if (auto observer = m_document->modalContainerObserverIfExists())
observer->revealModalContainer();
}
void continueHidingModalContainerAfterScope() { m_continueHidingModalContainerAfterScope = true; }
Document* document() const { return m_document.get(); }
private:
WeakPtr<Document> m_document;
bool m_continueHidingModalContainerAfterScope { false };
};
void ModalContainerObserver::collectClickableElementsTimerFired()
{
if (!container())
return;
container()->document().eventLoop().queueTask(TaskSource::InternalAsyncTask, [observer = this, decisionScope = ModalContainerPolicyDecisionScope { container()->document() }]() mutable {
RefPtr document = decisionScope.document();
if (!document)
return;
if (observer != document->modalContainerObserverIfExists() || !observer->container()) {
ASSERT_NOT_REACHED();
return;
}
auto [classifiableControls, controlTextsToClassify] = observer->collectClickableElements();
if (classifiableControls.isEmpty())
return;
auto* page = document->page();
if (!page) {
ASSERT_NOT_REACHED();
return;
}
page->chrome().client().classifyModalContainerControls(WTFMove(controlTextsToClassify), [decisionScope = WTFMove(decisionScope), observer, controls = WTFMove(classifiableControls)] (auto&& types) mutable {
RefPtr document = decisionScope.document();
if (!document)
return;
RefPtr documentLoader = document->loader();
if (!documentLoader) {
ASSERT_NOT_REACHED();
return;
}
if (observer != document->modalContainerObserverIfExists()) {
ASSERT_NOT_REACHED();
return;
}
if (types.size() != controls.size())
return;
struct ClassifiedControls {
Vector<WeakPtr<HTMLElement>> positive;
Vector<WeakPtr<HTMLElement>> neutral;
Vector<WeakPtr<HTMLElement>> negative;
HTMLElement* controlToClick(ModalContainerDecision decision) const
{
auto matchNonNull = [&](const WeakPtr<HTMLElement>& element) {
return !!element;
};
switch (decision) {
case ModalContainerDecision::Show:
case ModalContainerDecision::HideAndIgnore:
break;
case ModalContainerDecision::HideAndAllow:
if (auto index = positive.findIf(matchNonNull); index != notFound)
return positive[index].get();
if (auto index = neutral.findIf(matchNonNull); index != notFound)
return neutral[index].get();
break;
case ModalContainerDecision::HideAndDisallow:
if (auto index = negative.findIf(matchNonNull); index != notFound)
return negative[index].get();
break;
}
return nullptr;
}
OptionSet<ModalContainerControlType> types() const
{
OptionSet<ModalContainerControlType> availableTypesIgnoringOther;
if (!positive.isEmpty())
availableTypesIgnoringOther.add(ModalContainerControlType::Positive);
if (!negative.isEmpty())
availableTypesIgnoringOther.add(ModalContainerControlType::Negative);
if (!neutral.isEmpty())
availableTypesIgnoringOther.add(ModalContainerControlType::Neutral);
return availableTypesIgnoringOther;
}
};
ClassifiedControls classifiedControls;
for (size_t index = 0; index < types.size(); ++index) {
auto control = controls[index];
if (!control)
continue;
switch (types[index]) {
case ModalContainerControlType::Positive:
classifiedControls.positive.append(control);
break;
case ModalContainerControlType::Negative:
classifiedControls.negative.append(control);
break;
case ModalContainerControlType::Neutral:
classifiedControls.neutral.append(control);
break;
case ModalContainerControlType::Other:
break;
}
}
observer->m_hasAttemptedToFulfillPolicy = true;
auto* page = document->page();
if (!page)
return;
auto clickableControlTypes = classifiedControls.types();
if (clickableControlTypes.isEmpty())
return;
page->chrome().client().decidePolicyForModalContainer(clickableControlTypes, [decisionScope = WTFMove(decisionScope), observer, classifiedControls = WTFMove(classifiedControls)](auto decision) mutable {
RefPtr document = decisionScope.document();
if (!document)
return;
if (observer != document->modalContainerObserverIfExists()) {
ASSERT_NOT_REACHED();
return;
}
if (decision == ModalContainerDecision::Show)
return;
if (RefPtr controlToClick = classifiedControls.controlToClick(decision)) {
observer->clearScrollabilityOverrides(*document);
controlToClick->dispatchSimulatedClick(nullptr, SendMouseUpDownEvents, DoNotShowPressedLook);
}
decisionScope.continueHidingModalContainerAfterScope();
});
});
});
}
void ModalContainerObserver::makeBodyAndDocumentElementScrollableIfNeeded()
{
if (!container())
return;
Ref document = container()->document();
RefPtr view = document->view();
if (!view || view->isScrollable())
return;
document->updateLayoutIgnorePendingStylesheets();
auto visibleHeight = view->visibleSize().height();
auto shouldMakeElementScrollable = [visibleHeight] (Element* element) {
if (!element)
return false;
auto renderer = element->renderer();
if (!renderer || renderer->style().overflowY() != Overflow::Hidden)
return false;
return element->boundingClientRect().height() > visibleHeight;
};
if (!m_makeBodyElementScrollable) {
if (RefPtr body = document->body(); shouldMakeElementScrollable(body.get())) {
m_makeBodyElementScrollable = true;
body->invalidateStyle();
}
}
if (!m_makeDocumentElementScrollable) {
if (RefPtr documentElement = document->documentElement(); shouldMakeElementScrollable(documentElement.get())) {
m_makeDocumentElementScrollable = true;
documentElement->invalidateStyle();
}
}
}
void ModalContainerObserver::clearScrollabilityOverrides(Document& document)
{
if (std::exchange(m_makeBodyElementScrollable, false)) {
if (auto element = document.body())
element->invalidateStyle();
}
if (std::exchange(m_makeDocumentElementScrollable, false)) {
if (auto element = document.documentElement())
element->invalidateStyle();
}
}
void ModalContainerObserver::hideUserInteractionBlockingElementIfNeeded()
{
if (m_userInteractionBlockingElement)
return;
RefPtr container = this->container();
if (!container) {
ASSERT_NOT_REACHED();
return;
}
RefPtr view = container->document().view();
if (!view)
return;
auto fixedPositionRect = view->rectForFixedPositionLayout();
if (fixedPositionRect.isEmpty())
return;
FixedVector locationsToHitTest {
fixedPositionRect.center(),
fixedPositionRect.minXMinYCorner() + LayoutSize { 1, 1 },
fixedPositionRect.maxXMinYCorner() + LayoutSize { -1, 1 },
fixedPositionRect.minXMaxYCorner() + LayoutSize { 1, -1 },
fixedPositionRect.maxXMaxYCorner() + LayoutSize { -1, -1 }
};
constexpr OptionSet hitTestTypes { HitTestRequest::Type::ReadOnly, HitTestRequest::Type::DisallowUserAgentShadowContent };
RefPtr<Element> foundElement;
for (auto& location : locationsToHitTest) {
auto hitTestResult = view->frame().eventHandler().hitTestResultAtPoint(location, hitTestTypes);
auto target = hitTestResult.targetElement();
if (!target || is<HTMLBodyElement>(*target) || target->isDocumentNode())
return;
if (foundElement && foundElement != target)
return;
auto renderer = target->renderer();
if (!renderer || renderer->firstChild() || !renderer->style().hasViewportConstrainedPosition() || renderer->isDocumentElementRenderer())
return;
if (!foundElement)
foundElement = target;
}
m_userInteractionBlockingElement = foundElement.get();
foundElement->invalidateStyle();
}
void ModalContainerObserver::revealModalContainer()
{
auto [container, frameOwner] = std::exchange(m_containerAndFrameOwnerForControls, { });
if (container) {
container->invalidateStyle();
clearScrollabilityOverrides(container->document());
}
if (auto element = std::exchange(m_userInteractionBlockingElement, { }))
element->invalidateStyle();
}
std::pair<Vector<WeakPtr<HTMLElement>>, Vector<String>> ModalContainerObserver::collectClickableElements()
{
Ref container = *this->container();
m_collectingClickableElements = true;
auto exitCollectClickableElementsScope = makeScopeExit([&] {
m_collectingClickableElements = false;
container->invalidateStyle();
});
container->invalidateStyle();
container->document().updateLayoutIgnorePendingStylesheets();
auto containerForControls = ([&]() -> RefPtr<Element> {
auto frameOwner = frameOwnerForControls();
if (!frameOwner)
return container.ptr();
auto contentDocument = frameOwner->contentDocument();
if (!contentDocument)
return { };
return contentDocument->documentElement();
})();
if (!containerForControls)
return { };
auto containerListensForUserActivation = listensForUserActivation(*containerForControls) ? ContainerListensForUserActivation::Yes : ContainerListensForUserActivation::No;
Vector<Ref<HTMLElement>> clickableControls;
for (auto& child : descendantsOfType<HTMLElement>(*containerForControls)) {
if (isClickableControl(child, containerListensForUserActivation))
clickableControls.append(child);
}
removeElementsWithEmptyBounds(clickableControls);
removeParentOrChildElements(clickableControls);
Vector<WeakPtr<HTMLElement>> classifiableControls;
Vector<String> controlTextsToClassify;
classifiableControls.reserveInitialCapacity(clickableControls.size());
controlTextsToClassify.reserveInitialCapacity(clickableControls.size());
for (auto& control : clickableControls) {
auto text = textForControl(control).stripWhiteSpace();
if (!text.isEmpty() && text.length() < maxLengthForClickableElementText) {
classifiableControls.uncheckedAppend({ control });
controlTextsToClassify.uncheckedAppend(WTFMove(text));
}
}
return { WTFMove(classifiableControls), WTFMove(controlTextsToClassify) };
}
bool ModalContainerObserver::shouldMakeVerticallyScrollable(const Element& element) const
{
if (m_makeBodyElementScrollable && element.document().body() == &element)
return true;
if (m_makeDocumentElementScrollable && element.document().documentElement() == &element)
return true;
return false;
}
} // namespace WebCore