diff options
author | Lorry Tar Creator <lorry-tar-importer@lorry> | 2017-06-27 06:07:23 +0000 |
---|---|---|
committer | Lorry Tar Creator <lorry-tar-importer@lorry> | 2017-06-27 06:07:23 +0000 |
commit | 1bf1084f2b10c3b47fd1a588d85d21ed0eb41d0c (patch) | |
tree | 46dcd36c86e7fbc6e5df36deb463b33e9967a6f7 /Source/WebCore/accessibility/AXObjectCache.cpp | |
parent | 32761a6cee1d0dee366b885b7b9c777e67885688 (diff) | |
download | WebKitGtk-tarball-master.tar.gz |
webkitgtk-2.16.5HEADwebkitgtk-2.16.5master
Diffstat (limited to 'Source/WebCore/accessibility/AXObjectCache.cpp')
-rw-r--r-- | Source/WebCore/accessibility/AXObjectCache.cpp | 1956 |
1 files changed, 1832 insertions, 124 deletions
diff --git a/Source/WebCore/accessibility/AXObjectCache.cpp b/Source/WebCore/accessibility/AXObjectCache.cpp index fec39c7fb..0ceedd7d4 100644 --- a/Source/WebCore/accessibility/AXObjectCache.cpp +++ b/Source/WebCore/accessibility/AXObjectCache.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2008, 2009, 2010 Apple Inc. All rights reserved. + * Copyright (C) 2008, 2009, 2010, 2015 Apple Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions @@ -10,7 +10,7 @@ * 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. - * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of + * 3. Neither the name of Apple Inc. ("Apple") nor the names of * its contributors may be used to endorse or promote products derived * from this software without specific prior written permission. * @@ -35,20 +35,23 @@ #include "AccessibilityARIAGrid.h" #include "AccessibilityARIAGridCell.h" #include "AccessibilityARIAGridRow.h" +#include "AccessibilityAttachment.h" #include "AccessibilityImageMapLink.h" +#include "AccessibilityLabel.h" #include "AccessibilityList.h" #include "AccessibilityListBox.h" #include "AccessibilityListBoxOption.h" +#include "AccessibilityMathMLElement.h" #include "AccessibilityMediaControls.h" #include "AccessibilityMenuList.h" #include "AccessibilityMenuListOption.h" #include "AccessibilityMenuListPopup.h" #include "AccessibilityProgressIndicator.h" #include "AccessibilityRenderObject.h" +#include "AccessibilitySVGElement.h" #include "AccessibilitySVGRoot.h" #include "AccessibilityScrollView.h" #include "AccessibilityScrollbar.h" -#include "AccessibilitySearchFieldButtons.h" #include "AccessibilitySlider.h" #include "AccessibilitySpinButton.h" #include "AccessibilityTable.h" @@ -56,37 +59,61 @@ #include "AccessibilityTableColumn.h" #include "AccessibilityTableHeaderContainer.h" #include "AccessibilityTableRow.h" +#include "AccessibilityTree.h" +#include "AccessibilityTreeItem.h" #include "Document.h" #include "Editor.h" +#include "ElementIterator.h" #include "FocusController.h" #include "Frame.h" #include "HTMLAreaElement.h" +#include "HTMLCanvasElement.h" #include "HTMLImageElement.h" #include "HTMLInputElement.h" #include "HTMLLabelElement.h" #include "HTMLMeterElement.h" #include "HTMLNames.h" +#include "InlineElementBox.h" +#include "MathMLElement.h" #include "Page.h" +#include "RenderAttachment.h" +#include "RenderLineBreak.h" #include "RenderListBox.h" +#include "RenderMathMLOperator.h" #include "RenderMenuList.h" #include "RenderMeter.h" #include "RenderProgress.h" +#include "RenderSVGRoot.h" #include "RenderSlider.h" #include "RenderTable.h" #include "RenderTableCell.h" #include "RenderTableRow.h" #include "RenderView.h" +#include "SVGElement.h" #include "ScrollView.h" -#include <wtf/PassRefPtr.h> +#include "TextBoundaries.h" +#include "TextIterator.h" +#include "htmlediting.h" +#include <wtf/DataLog.h> #if ENABLE(VIDEO) #include "MediaControlElements.h" #endif +#if COMPILER(MSVC) +// See https://msdn.microsoft.com/en-us/library/1wea5zwe.aspx +#pragma warning(disable: 4701) +#endif + namespace WebCore { using namespace HTMLNames; +// Post value change notifications for password fields or elements contained in password fields at a 40hz interval to thwart analysis of typing cadence +static double AccessibilityPasswordValueChangeNotificationInterval = 0.025; +static double AccessibilityLiveRegionChangedNotificationInterval = 0.020; +static double AccessibilityFocusAriaModalNodeNotificationInterval = 0.050; + AccessibilityObjectInclusion AXComputedObjectAttributeCache::getIgnored(AXID id) const { HashMap<AXID, CachedAXObjectAttributes>::const_iterator it = m_idMapping.find(id); @@ -104,7 +131,34 @@ void AXComputedObjectAttributeCache::setIgnored(AXID id, AccessibilityObjectIncl m_idMapping.set(id, attributes); } } - + +AccessibilityReplacedText::AccessibilityReplacedText(const VisibleSelection& selection) +{ + if (AXObjectCache::accessibilityEnabled()) { + m_replacedRange.startIndex.value = indexForVisiblePosition(selection.start(), m_replacedRange.startIndex.scope); + if (selection.isRange()) { + m_replacedText = AccessibilityObject::stringForVisiblePositionRange(selection); + m_replacedRange.endIndex.value = indexForVisiblePosition(selection.end(), m_replacedRange.endIndex.scope); + } else + m_replacedRange.endIndex = m_replacedRange.startIndex; + } +} + +void AccessibilityReplacedText::postTextStateChangeNotification(AXObjectCache* cache, AXTextEditType type, const String& text, const VisibleSelection& selection) +{ + if (!cache) + return; + if (!AXObjectCache::accessibilityEnabled()) + return; + + VisiblePosition position = selection.start(); + auto* node = highestEditableRoot(position.deepEquivalent(), HasEditableAXRole); + if (m_replacedText.length()) + cache->postTextReplacementNotification(node, AXTextEditTypeDelete, m_replacedText, type, text, position); + else + cache->postTextStateChangeNotification(node, type, text, position); +} + bool AXObjectCache::gAccessibilityEnabled = false; bool AXObjectCache::gAccessibilityEnhancedUserInterfaceEnabled = false; @@ -118,15 +172,33 @@ void AXObjectCache::disableAccessibility() gAccessibilityEnabled = false; } +void AXObjectCache::setEnhancedUserInterfaceAccessibility(bool flag) +{ + gAccessibilityEnhancedUserInterfaceEnabled = flag; +#if PLATFORM(MAC) +#if __MAC_OS_X_VERSION_MIN_REQUIRED >= 101100 + if (flag) + enableAccessibility(); +#endif +#endif +} + AXObjectCache::AXObjectCache(Document& document) : m_document(document) - , m_notificationPostTimer(this, &AXObjectCache::notificationPostTimerFired) + , m_notificationPostTimer(*this, &AXObjectCache::notificationPostTimerFired) + , m_passwordNotificationPostTimer(*this, &AXObjectCache::passwordNotificationPostTimerFired) + , m_liveRegionChangedPostTimer(*this, &AXObjectCache::liveRegionChangedNotificationPostTimerFired) + , m_focusAriaModalNodeTimer(*this, &AXObjectCache::focusAriaModalNodeTimerFired) + , m_currentAriaModalNode(nullptr) { + findAriaModalNodes(); } AXObjectCache::~AXObjectCache() { m_notificationPostTimer.stop(); + m_liveRegionChangedPostTimer.stop(); + m_focusAriaModalNodeTimer.stop(); for (const auto& object : m_objects.values()) { detachWrapper(object.get(), CacheDestroyed); @@ -135,46 +207,131 @@ AXObjectCache::~AXObjectCache() } } +void AXObjectCache::findAriaModalNodes() +{ + // Traverse the DOM tree to look for the aria-modal=true nodes. + for (Element* element = ElementTraversal::firstWithin(document().rootNode()); element; element = ElementTraversal::nextIncludingPseudo(*element)) { + + // Must have dialog or alertdialog role + if (!nodeHasRole(element, "dialog") && !nodeHasRole(element, "alertdialog")) + continue; + if (!equalLettersIgnoringASCIICase(element->attributeWithoutSynchronization(aria_modalAttr), "true")) + continue; + + m_ariaModalNodesSet.add(element); + } + + // Set the current valid aria-modal node if possible. + updateCurrentAriaModalNode(); +} + +void AXObjectCache::updateCurrentAriaModalNode() +{ + // There might be multiple nodes with aria-modal=true set. + // We use this function to pick the one we want. + m_currentAriaModalNode = nullptr; + if (m_ariaModalNodesSet.isEmpty()) + return; + + // We only care about the nodes which are visible. + ListHashSet<RefPtr<Node>> visibleNodes; + for (auto& object : m_ariaModalNodesSet) { + if (isNodeVisible(object)) + visibleNodes.add(object); + } + + if (visibleNodes.isEmpty()) + return; + + // If any of the node are keyboard focused, we want to pick that. + Node* focusedNode = document().focusedElement(); + for (auto& object : visibleNodes) { + if (focusedNode != nullptr && focusedNode->isDescendantOf(object.get())) { + m_currentAriaModalNode = object.get(); + break; + } + } + + // If none of the nodes are focused, we want to pick the last dialog in the DOM. + if (!m_currentAriaModalNode) + m_currentAriaModalNode = visibleNodes.last().get(); +} + +bool AXObjectCache::isNodeVisible(Node* node) const +{ + if (!is<Element>(node)) + return false; + + RenderObject* renderer = node->renderer(); + if (!renderer) + return false; + const RenderStyle& style = renderer->style(); + if (style.display() == NONE || style.visibility() != VISIBLE) + return false; + + // We also need to consider aria hidden status. + if (!isNodeAriaVisible(node)) + return false; + + return true; +} + +Node* AXObjectCache::ariaModalNode() +{ + // This function returns the valid aria modal node. + if (m_ariaModalNodesSet.isEmpty()) + return nullptr; + + // Check the current valid aria modal node first. + // Usually when one dialog sets aria-modal=true, that dialog is the one we want. + if (isNodeVisible(m_currentAriaModalNode)) + return m_currentAriaModalNode; + + // Recompute the valid aria modal node when m_currentAriaModalNode is null or hidden. + updateCurrentAriaModalNode(); + return isNodeVisible(m_currentAriaModalNode) ? m_currentAriaModalNode : nullptr; +} + AccessibilityObject* AXObjectCache::focusedImageMapUIElement(HTMLAreaElement* areaElement) { // Find the corresponding accessibility object for the HTMLAreaElement. This should be // in the list of children for its corresponding image. if (!areaElement) - return 0; + return nullptr; HTMLImageElement* imageElement = areaElement->imageElement(); if (!imageElement) - return 0; + return nullptr; AccessibilityObject* axRenderImage = areaElement->document().axObjectCache()->getOrCreate(imageElement); if (!axRenderImage) - return 0; + return nullptr; for (const auto& child : axRenderImage->children()) { - if (!child->isImageMapLink()) + if (!is<AccessibilityImageMapLink>(*child)) continue; - if (toAccessibilityImageMapLink(child.get())->areaElement() == areaElement) + if (downcast<AccessibilityImageMapLink>(*child).areaElement() == areaElement) return child.get(); } - return 0; + return nullptr; } AccessibilityObject* AXObjectCache::focusedUIElementForPage(const Page* page) { if (!gAccessibilityEnabled) - return 0; + return nullptr; // get the focused node in the page Document* focusedDocument = page->focusController().focusedOrMainFrame().document(); Element* focusedElement = focusedDocument->focusedElement(); - if (focusedElement && isHTMLAreaElement(focusedElement)) - return focusedImageMapUIElement(toHTMLAreaElement(focusedElement)); + if (is<HTMLAreaElement>(focusedElement)) + return focusedImageMapUIElement(downcast<HTMLAreaElement>(focusedElement)); AccessibilityObject* obj = focusedDocument->axObjectCache()->getOrCreate(focusedElement ? static_cast<Node*>(focusedElement) : focusedDocument); if (!obj) - return 0; + return nullptr; if (obj->shouldFocusActiveDescendant()) { if (AccessibilityObject* descendant = obj->activeDescendant()) @@ -191,12 +348,12 @@ AccessibilityObject* AXObjectCache::focusedUIElementForPage(const Page* page) AccessibilityObject* AXObjectCache::get(Widget* widget) { if (!widget) - return 0; + return nullptr; AXID axID = m_widgetObjectMapping.get(widget); ASSERT(!HashTraits<AXID>::isDeletedValue(axID)); if (!axID) - return 0; + return nullptr; return m_objects.get(axID); } @@ -204,12 +361,12 @@ AccessibilityObject* AXObjectCache::get(Widget* widget) AccessibilityObject* AXObjectCache::get(RenderObject* renderer) { if (!renderer) - return 0; + return nullptr; AXID axID = m_renderObjectMapping.get(renderer); ASSERT(!HashTraits<AXID>::isDeletedValue(axID)); if (!axID) - return 0; + return nullptr; return m_objects.get(axID); } @@ -217,7 +374,7 @@ AccessibilityObject* AXObjectCache::get(RenderObject* renderer) AccessibilityObject* AXObjectCache::get(Node* node) { if (!node) - return 0; + return nullptr; AXID renderID = node->renderer() ? m_renderObjectMapping.get(node->renderer()) : 0; ASSERT(!HashTraits<AXID>::isDeletedValue(renderID)); @@ -230,14 +387,14 @@ AccessibilityObject* AXObjectCache::get(Node* node) // rendered, but later something changes and it gets a renderer (like if it's // reparented). remove(nodeID); - return 0; + return nullptr; } if (renderID) return m_objects.get(renderID); if (!nodeID) - return 0; + return nullptr; return m_objects.get(nodeID); } @@ -246,13 +403,19 @@ AccessibilityObject* AXObjectCache::get(Node* node) // FIXME: This should take a const char*, but one caller passes nullAtom. bool nodeHasRole(Node* node, const String& role) { - if (!node || !node->isElementNode()) + if (!node || !is<Element>(node)) return false; - return equalIgnoringCase(toElement(node)->fastGetAttribute(roleAttr), role); + auto& roleValue = downcast<Element>(*node).attributeWithoutSynchronization(roleAttr); + if (role.isNull()) + return roleValue.isEmpty(); + if (roleValue.isEmpty()) + return false; + + return SpaceSplitString(roleValue, true).contains(role); } -static PassRefPtr<AccessibilityObject> createFromRenderer(RenderObject* renderer) +static Ref<AccessibilityObject> createFromRenderer(RenderObject* renderer) { // FIXME: How could renderer->node() ever not be an Element? Node* node = renderer->node(); @@ -264,62 +427,80 @@ static PassRefPtr<AccessibilityObject> createFromRenderer(RenderObject* renderer return AccessibilityList::create(renderer); // aria tables - if (nodeHasRole(node, "grid") || nodeHasRole(node, "treegrid")) + if (nodeHasRole(node, "grid") || nodeHasRole(node, "treegrid") || nodeHasRole(node, "table")) return AccessibilityARIAGrid::create(renderer); if (nodeHasRole(node, "row")) return AccessibilityARIAGridRow::create(renderer); - if (nodeHasRole(node, "gridcell") || nodeHasRole(node, "columnheader") || nodeHasRole(node, "rowheader")) + if (nodeHasRole(node, "gridcell") || nodeHasRole(node, "cell") || nodeHasRole(node, "columnheader") || nodeHasRole(node, "rowheader")) return AccessibilityARIAGridCell::create(renderer); + // aria tree + if (nodeHasRole(node, "tree")) + return AccessibilityTree::create(renderer); + if (nodeHasRole(node, "treeitem")) + return AccessibilityTreeItem::create(renderer); + + if (node && is<HTMLLabelElement>(node) && nodeHasRole(node, nullAtom)) + return AccessibilityLabel::create(renderer); + #if ENABLE(VIDEO) // media controls if (node && node->isMediaControlElement()) return AccessibilityMediaControl::create(renderer); #endif -#if ENABLE(SVG) - if (renderer->isSVGRoot()) + if (is<RenderSVGRoot>(*renderer)) return AccessibilitySVGRoot::create(renderer); -#endif - - // Search field buttons - if (node && node->isElementNode() && toElement(node)->isSearchFieldCancelButtonElement()) - return AccessibilitySearchFieldCancelButton::create(renderer); - if (renderer->isBoxModelObject()) { - RenderBoxModelObject* cssBox = toRenderBoxModelObject(renderer); - if (cssBox->isListBox()) - return AccessibilityListBox::create(toRenderListBox(cssBox)); - if (cssBox->isMenuList()) - return AccessibilityMenuList::create(toRenderMenuList(cssBox)); + if (is<SVGElement>(node)) + return AccessibilitySVGElement::create(renderer); + +#if ENABLE(MATHML) + // The mfenced element creates anonymous RenderMathMLOperators which should be treated + // as MathML elements and assigned the MathElementRole so that platform logic regarding + // inclusion and role mapping is not bypassed. + bool isAnonymousOperator = renderer->isAnonymous() && is<RenderMathMLOperator>(*renderer); + if (isAnonymousOperator || is<MathMLElement>(node)) + return AccessibilityMathMLElement::create(renderer, isAnonymousOperator); +#endif + + if (is<RenderBoxModelObject>(*renderer)) { + RenderBoxModelObject& cssBox = downcast<RenderBoxModelObject>(*renderer); + if (is<RenderListBox>(cssBox)) + return AccessibilityListBox::create(&downcast<RenderListBox>(cssBox)); + if (is<RenderMenuList>(cssBox)) + return AccessibilityMenuList::create(&downcast<RenderMenuList>(cssBox)); // standard tables - if (cssBox->isTable()) - return AccessibilityTable::create(toRenderTable(cssBox)); - if (cssBox->isTableRow()) - return AccessibilityTableRow::create(toRenderTableRow(cssBox)); - if (cssBox->isTableCell()) - return AccessibilityTableCell::create(toRenderTableCell(cssBox)); - -#if ENABLE(PROGRESS_ELEMENT) + if (is<RenderTable>(cssBox)) + return AccessibilityTable::create(&downcast<RenderTable>(cssBox)); + if (is<RenderTableRow>(cssBox)) + return AccessibilityTableRow::create(&downcast<RenderTableRow>(cssBox)); + if (is<RenderTableCell>(cssBox)) + return AccessibilityTableCell::create(&downcast<RenderTableCell>(cssBox)); + // progress bar - if (cssBox->isProgress()) - return AccessibilityProgressIndicator::create(toRenderProgress(cssBox)); + if (is<RenderProgress>(cssBox)) + return AccessibilityProgressIndicator::create(&downcast<RenderProgress>(cssBox)); + +#if ENABLE(ATTACHMENT_ELEMENT) + if (is<RenderAttachment>(cssBox)) + return AccessibilityAttachment::create(&downcast<RenderAttachment>(cssBox)); #endif #if ENABLE(METER_ELEMENT) - if (cssBox->isMeter()) - return AccessibilityProgressIndicator::create(toRenderMeter(cssBox)); + if (is<RenderMeter>(cssBox)) + return AccessibilityProgressIndicator::create(&downcast<RenderMeter>(cssBox)); #endif // input type=range - if (cssBox->isSlider()) - return AccessibilitySlider::create(toRenderSlider(cssBox)); + if (is<RenderSlider>(cssBox)) + return AccessibilitySlider::create(&downcast<RenderSlider>(cssBox)); } return AccessibilityRenderObject::create(renderer); } -static PassRefPtr<AccessibilityObject> createFromNode(Node* node) +static Ref<AccessibilityObject> createFromNode(Node* node) { return AccessibilityNodeObject::create(node); } @@ -327,16 +508,16 @@ static PassRefPtr<AccessibilityObject> createFromNode(Node* node) AccessibilityObject* AXObjectCache::getOrCreate(Widget* widget) { if (!widget) - return 0; + return nullptr; if (AccessibilityObject* obj = get(widget)) return obj; - RefPtr<AccessibilityObject> newObj = 0; - if (widget->isFrameView()) - newObj = AccessibilityScrollView::create(toScrollView(widget)); - else if (widget->isScrollbar()) - newObj = AccessibilityScrollbar::create(toScrollbar(widget)); + RefPtr<AccessibilityObject> newObj; + if (is<ScrollView>(*widget)) + newObj = AccessibilityScrollView::create(downcast<ScrollView>(widget)); + else if (is<Scrollbar>(*widget)) + newObj = AccessibilityScrollbar::create(downcast<Scrollbar>(widget)); // Will crash later if we have two objects for the same widget. ASSERT(!get(widget)); @@ -344,7 +525,7 @@ AccessibilityObject* AXObjectCache::getOrCreate(Widget* widget) // Catch the case if an (unsupported) widget type is used. Only FrameView and ScrollBar are supported now. ASSERT(newObj); if (!newObj) - return 0; + return nullptr; getAXID(newObj.get()); @@ -358,7 +539,7 @@ AccessibilityObject* AXObjectCache::getOrCreate(Widget* widget) AccessibilityObject* AXObjectCache::getOrCreate(Node* node) { if (!node) - return 0; + return nullptr; if (AccessibilityObject* obj = get(node)) return obj; @@ -367,20 +548,25 @@ AccessibilityObject* AXObjectCache::getOrCreate(Node* node) return getOrCreate(node->renderer()); if (!node->parentElement()) - return 0; + return nullptr; // It's only allowed to create an AccessibilityObject from a Node if it's in a canvas subtree. // Or if it's a hidden element, but we still want to expose it because of other ARIA attributes. - bool inCanvasSubtree = node->parentElement()->isInCanvasSubtree(); - bool isHidden = !node->renderer() && isNodeAriaVisible(node); + bool inCanvasSubtree = lineageOfType<HTMLCanvasElement>(*node->parentElement()).first(); + bool isHidden = isNodeAriaVisible(node); bool insideMeterElement = false; #if ENABLE(METER_ELEMENT) - insideMeterElement = isHTMLMeterElement(node->parentElement()); + insideMeterElement = is<HTMLMeterElement>(*node->parentElement()); #endif if (!inCanvasSubtree && !isHidden && !insideMeterElement) - return 0; + return nullptr; + + // Fallback content is only focusable as long as the canvas is displayed and visible. + // Update the style before Element::isFocusable() gets called. + if (inCanvasSubtree) + node->document().updateStyleIfNeeded(); RefPtr<AccessibilityObject> newObj = createFromNode(node); @@ -405,7 +591,7 @@ AccessibilityObject* AXObjectCache::getOrCreate(Node* node) AccessibilityObject* AXObjectCache::getOrCreate(RenderObject* renderer) { if (!renderer) - return 0; + return nullptr; if (AccessibilityObject* obj = get(renderer)) return obj; @@ -433,7 +619,7 @@ AccessibilityObject* AXObjectCache::getOrCreate(RenderObject* renderer) AccessibilityObject* AXObjectCache::rootObject() { if (!gAccessibilityEnabled) - return 0; + return nullptr; return getOrCreate(m_document.view()); } @@ -441,16 +627,16 @@ AccessibilityObject* AXObjectCache::rootObject() AccessibilityObject* AXObjectCache::rootObjectForFrame(Frame* frame) { if (!gAccessibilityEnabled) - return 0; + return nullptr; if (!frame) - return 0; + return nullptr; return getOrCreate(frame->view()); } AccessibilityObject* AXObjectCache::getOrCreate(AccessibilityRole role) { - RefPtr<AccessibilityObject> obj = 0; + RefPtr<AccessibilityObject> obj = nullptr; // will be filled in... switch (role) { @@ -482,13 +668,13 @@ AccessibilityObject* AXObjectCache::getOrCreate(AccessibilityRole role) obj = AccessibilitySpinButtonPart::create(); break; default: - obj = 0; + obj = nullptr; } if (obj) getAXID(obj.get()); else - return 0; + return nullptr; m_objects.set(obj->axObjectID(), obj); obj->init(); @@ -525,6 +711,8 @@ void AXObjectCache::remove(RenderObject* renderer) AXID axID = m_renderObjectMapping.get(renderer); remove(axID); m_renderObjectMapping.remove(renderer); + if (is<RenderBlock>(*renderer)) + m_deferredIsIgnoredChangeList.remove(downcast<RenderBlock>(renderer)); } void AXObjectCache::remove(Node* node) @@ -539,6 +727,12 @@ void AXObjectCache::remove(Node* node) remove(axID); m_nodeObjectMapping.remove(node); + // Cleanup for aria modal nodes. + if (m_currentAriaModalNode == node) + m_currentAriaModalNode = nullptr; + if (m_ariaModalNodesSet.contains(node)) + m_ariaModalNodesSet.remove(node); + if (node->renderer()) { remove(node->renderer()); return; @@ -556,7 +750,7 @@ void AXObjectCache::remove(Widget* view) } -#if !PLATFORM(WIN) || OS(WINCE) +#if !PLATFORM(WIN) AXID AXObjectCache::platformGenerateAXID() const { static AXID lastUsedID = 0; @@ -641,8 +835,30 @@ void AXObjectCache::handleMenuOpened(Node* node) postNotification(getOrCreate(node), &document(), AXMenuOpened); } -void AXObjectCache::childrenChanged(Node* node) +void AXObjectCache::handleLiveRegionCreated(Node* node) +{ + if (!is<Element>(node) || !node->renderer()) + return; + + Element* element = downcast<Element>(node); + String liveRegionStatus = element->attributeWithoutSynchronization(aria_liveAttr); + if (liveRegionStatus.isEmpty()) { + const AtomicString& ariaRole = element->attributeWithoutSynchronization(roleAttr); + if (!ariaRole.isEmpty()) + liveRegionStatus = AccessibilityObject::defaultLiveRegionStatusForRole(AccessibilityObject::ariaRoleToWebCoreRole(ariaRole)); + } + + if (AccessibilityObject::liveRegionStatusIsEnabled(liveRegionStatus)) + postNotification(getOrCreate(node), &document(), AXLiveRegionCreated); +} + +void AXObjectCache::childrenChanged(Node* node, Node* newChild) { + if (newChild) { + handleMenuOpened(newChild); + handleLiveRegionCreated(newChild); + } + childrenChanged(get(node)); } @@ -650,8 +866,11 @@ void AXObjectCache::childrenChanged(RenderObject* renderer, RenderObject* newChi { if (!renderer) return; - if (newChild) + + if (newChild) { handleMenuOpened(newChild->node()); + handleLiveRegionCreated(newChild->node()); + } childrenChanged(get(renderer)); } @@ -664,15 +883,14 @@ void AXObjectCache::childrenChanged(AccessibilityObject* obj) obj->childrenChanged(); } -void AXObjectCache::notificationPostTimerFired(Timer<AXObjectCache>&) +void AXObjectCache::notificationPostTimerFired() { Ref<Document> protectorForCacheOwner(m_document); m_notificationPostTimer.stop(); - // In DRT, posting notifications has a tendency to immediately queue up other notifications, which can lead to unexpected behavior + // In tests, posting notifications has a tendency to immediately queue up other notifications, which can lead to unexpected behavior // when the notification list is cleared at the end. Instead copy this list at the start. - auto notifications = m_notificationsToPost; - m_notificationsToPost.clear(); + auto notifications = WTFMove(m_notificationsToPost); for (const auto& note : notifications) { AccessibilityObject* obj = note.first.get(); @@ -685,21 +903,42 @@ void AXObjectCache::notificationPostTimerFired(Timer<AXObjectCache>&) #ifndef NDEBUG // Make sure none of the render views are in the process of being layed out. // Notifications should only be sent after the renderer has finished - if (obj->isAccessibilityRenderObject()) { - AccessibilityRenderObject* renderObj = toAccessibilityRenderObject(obj); - RenderObject* renderer = renderObj->renderer(); - if (renderer) + if (is<AccessibilityRenderObject>(*obj)) { + if (auto* renderer = downcast<AccessibilityRenderObject>(*obj).renderer()) ASSERT(!renderer->view().layoutState()); } #endif AXNotification notification = note.second; + + // Ensure that this menu really is a menu. We do this check here so that we don't have to create + // the axChildren when the menu is marked as opening. + if (notification == AXMenuOpened) { + obj->updateChildrenIfNecessary(); + if (obj->roleValue() != MenuRole) + continue; + } + postPlatformNotification(obj, notification); if (notification == AXChildrenChanged && obj->parentObjectIfExists() && obj->lastKnownIsIgnoredValue() != obj->accessibilityIsIgnored()) childrenChanged(obj->parentObject()); } } + +void AXObjectCache::passwordNotificationPostTimerFired() +{ +#if PLATFORM(COCOA) + m_passwordNotificationPostTimer.stop(); + + // In tests, posting notifications has a tendency to immediately queue up other notifications, which can lead to unexpected behavior + // when the notification list is cleared at the end. Instead copy this list at the start. + auto notifications = WTFMove(m_passwordNotificationsToPost); + + for (auto& notification : notifications) + postTextStateChangePlatformNotification(notification.get(), AXTextEditTypeInsert, " ", VisiblePosition()); +#endif +} void AXObjectCache::postNotification(RenderObject* renderer, AXNotification notification, PostTarget postTarget, PostType postType) { @@ -777,7 +1016,7 @@ void AXObjectCache::handleMenuItemSelected(Node* node) if (!nodeHasRole(node, "menuitem") && !nodeHasRole(node, "menuitemradio") && !nodeHasRole(node, "menuitemcheckbox")) return; - if (!toElement(node)->focused() && !equalIgnoringCase(toElement(node)->fastGetAttribute(aria_selectedAttr), "true")) + if (!downcast<Element>(*node).focused() && !equalLettersIgnoringASCIICase(downcast<Element>(*node).attributeWithoutSynchronization(aria_selectedAttr), "true")) return; postNotification(getOrCreate(node), &document(), AXMenuListItemSelected); @@ -808,16 +1047,273 @@ void AXObjectCache::selectedChildrenChanged(RenderObject* renderer) postNotification(renderer, AXSelectedChildrenChanged, TargetObservableParent); } -void AXObjectCache::nodeTextChangeNotification(Node* node, AXTextChange textChange, unsigned offset, const String& text) +#ifndef NDEBUG +void AXObjectCache::showIntent(const AXTextStateChangeIntent &intent) +{ + switch (intent.type) { + case AXTextStateChangeTypeUnknown: + dataLog("Unknown"); + break; + case AXTextStateChangeTypeEdit: + dataLog("Edit::"); + break; + case AXTextStateChangeTypeSelectionMove: + dataLog("Move::"); + break; + case AXTextStateChangeTypeSelectionExtend: + dataLog("Extend::"); + break; + case AXTextStateChangeTypeSelectionBoundary: + dataLog("Boundary::"); + break; + } + switch (intent.type) { + case AXTextStateChangeTypeUnknown: + break; + case AXTextStateChangeTypeEdit: + switch (intent.change) { + case AXTextEditTypeUnknown: + dataLog("Unknown"); + break; + case AXTextEditTypeDelete: + dataLog("Delete"); + break; + case AXTextEditTypeInsert: + dataLog("Insert"); + break; + case AXTextEditTypeDictation: + dataLog("DictationInsert"); + break; + case AXTextEditTypeTyping: + dataLog("TypingInsert"); + break; + case AXTextEditTypeCut: + dataLog("Cut"); + break; + case AXTextEditTypePaste: + dataLog("Paste"); + break; + case AXTextEditTypeAttributesChange: + dataLog("AttributesChange"); + break; + } + break; + case AXTextStateChangeTypeSelectionMove: + case AXTextStateChangeTypeSelectionExtend: + case AXTextStateChangeTypeSelectionBoundary: + switch (intent.selection.direction) { + case AXTextSelectionDirectionUnknown: + dataLog("Unknown::"); + break; + case AXTextSelectionDirectionBeginning: + dataLog("Beginning::"); + break; + case AXTextSelectionDirectionEnd: + dataLog("End::"); + break; + case AXTextSelectionDirectionPrevious: + dataLog("Previous::"); + break; + case AXTextSelectionDirectionNext: + dataLog("Next::"); + break; + case AXTextSelectionDirectionDiscontiguous: + dataLog("Discontiguous::"); + break; + } + switch (intent.selection.direction) { + case AXTextSelectionDirectionUnknown: + case AXTextSelectionDirectionBeginning: + case AXTextSelectionDirectionEnd: + case AXTextSelectionDirectionPrevious: + case AXTextSelectionDirectionNext: + switch (intent.selection.granularity) { + case AXTextSelectionGranularityUnknown: + dataLog("Unknown"); + break; + case AXTextSelectionGranularityCharacter: + dataLog("Character"); + break; + case AXTextSelectionGranularityWord: + dataLog("Word"); + break; + case AXTextSelectionGranularityLine: + dataLog("Line"); + break; + case AXTextSelectionGranularitySentence: + dataLog("Sentence"); + break; + case AXTextSelectionGranularityParagraph: + dataLog("Paragraph"); + break; + case AXTextSelectionGranularityPage: + dataLog("Page"); + break; + case AXTextSelectionGranularityDocument: + dataLog("Document"); + break; + case AXTextSelectionGranularityAll: + dataLog("All"); + break; + } + break; + case AXTextSelectionDirectionDiscontiguous: + break; + } + break; + } + dataLog("\n"); +} +#endif + +void AXObjectCache::setTextSelectionIntent(const AXTextStateChangeIntent& intent) +{ + m_textSelectionIntent = intent; +} + +void AXObjectCache::setIsSynchronizingSelection(bool isSynchronizing) +{ + m_isSynchronizingSelection = isSynchronizing; +} + +static bool isPasswordFieldOrContainedByPasswordField(AccessibilityObject* object) +{ + return object && (object->isPasswordField() || object->isContainedByPasswordField()); +} + +void AXObjectCache::postTextStateChangeNotification(Node* node, const AXTextStateChangeIntent& intent, const VisibleSelection& selection) { if (!node) return; +#if PLATFORM(COCOA) stopCachingComputedObjectAttributes(); - // Delegate on the right platform - AccessibilityObject* obj = getOrCreate(node); - nodeTextChangePlatformNotification(obj, textChange, offset, text); + postTextStateChangeNotification(getOrCreate(node), intent, selection); +#else + postNotification(node->renderer(), AXObjectCache::AXSelectedTextChanged, TargetObservableParent); + UNUSED_PARAM(intent); + UNUSED_PARAM(selection); +#endif +} + +void AXObjectCache::postTextStateChangeNotification(const Position& position, const AXTextStateChangeIntent& intent, const VisibleSelection& selection) +{ + Node* node = position.deprecatedNode(); + if (!node) + return; + + stopCachingComputedObjectAttributes(); + +#if PLATFORM(COCOA) + AccessibilityObject* object = getOrCreate(node); + if (object && object->accessibilityIsIgnored()) { + if (position.atLastEditingPositionForNode()) { + if (AccessibilityObject* nextSibling = object->nextSiblingUnignored(1)) + object = nextSibling; + } else if (position.atFirstEditingPositionForNode()) { + if (AccessibilityObject* previousSibling = object->previousSiblingUnignored(1)) + object = previousSibling; + } + } + + postTextStateChangeNotification(object, intent, selection); +#else + postTextStateChangeNotification(node, intent, selection); +#endif +} + +void AXObjectCache::postTextStateChangeNotification(AccessibilityObject* object, const AXTextStateChangeIntent& intent, const VisibleSelection& selection) +{ + stopCachingComputedObjectAttributes(); + +#if PLATFORM(COCOA) + if (object) { + if (isPasswordFieldOrContainedByPasswordField(object)) + return; + + if (auto observableObject = object->observableObject()) + object = observableObject; + } + + const AXTextStateChangeIntent& newIntent = (intent.type == AXTextStateChangeTypeUnknown || (m_isSynchronizingSelection && m_textSelectionIntent.type != AXTextStateChangeTypeUnknown)) ? m_textSelectionIntent : intent; + postTextStateChangePlatformNotification(object, newIntent, selection); +#else + UNUSED_PARAM(object); + UNUSED_PARAM(intent); + UNUSED_PARAM(selection); +#endif + + setTextSelectionIntent(AXTextStateChangeIntent()); + setIsSynchronizingSelection(false); +} + +void AXObjectCache::postTextStateChangeNotification(Node* node, AXTextEditType type, const String& text, const VisiblePosition& position) +{ + if (!node) + return; + if (type == AXTextEditTypeUnknown) + return; + + stopCachingComputedObjectAttributes(); + + AccessibilityObject* object = getOrCreate(node); +#if PLATFORM(COCOA) + if (object) { + if (enqueuePasswordValueChangeNotification(object)) + return; + object = object->observableObject(); + } + + postTextStateChangePlatformNotification(object, type, text, position); +#else + nodeTextChangePlatformNotification(object, textChangeForEditType(type), position.deepEquivalent().deprecatedEditingOffset(), text); +#endif +} + +void AXObjectCache::postTextReplacementNotification(Node* node, AXTextEditType deletionType, const String& deletedText, AXTextEditType insertionType, const String& insertedText, const VisiblePosition& position) +{ + if (!node) + return; + if (deletionType != AXTextEditTypeDelete) + return; + if (!(insertionType == AXTextEditTypeInsert || insertionType == AXTextEditTypeTyping || insertionType == AXTextEditTypeDictation || insertionType == AXTextEditTypePaste)) + return; + + stopCachingComputedObjectAttributes(); + + AccessibilityObject* object = getOrCreate(node); +#if PLATFORM(COCOA) + if (object) { + if (enqueuePasswordValueChangeNotification(object)) + return; + object = object->observableObject(); + } + + postTextReplacementPlatformNotification(object, deletionType, deletedText, insertionType, insertedText, position); +#else + nodeTextChangePlatformNotification(object, textChangeForEditType(deletionType), position.deepEquivalent().deprecatedEditingOffset(), deletedText); + nodeTextChangePlatformNotification(object, textChangeForEditType(insertionType), position.deepEquivalent().deprecatedEditingOffset(), insertedText); +#endif +} + +bool AXObjectCache::enqueuePasswordValueChangeNotification(AccessibilityObject* object) +{ + if (!isPasswordFieldOrContainedByPasswordField(object)) + return false; + + AccessibilityObject* observableObject = object->observableObject(); + if (!observableObject) { + ASSERT_NOT_REACHED(); + // return true even though the enqueue didn't happen because this is a password field and caller shouldn't post a notification + return true; + } + + m_passwordNotificationsToPost.add(observableObject); + if (!m_passwordNotificationPostTimer.isActive()) + m_passwordNotificationPostTimer.startOneShot(AccessibilityPasswordValueChangeNotificationInterval); + + return true; } void AXObjectCache::frameLoadingEventNotification(Frame* frame, AXLoadingEvent loadingEvent) @@ -834,6 +1330,67 @@ void AXObjectCache::frameLoadingEventNotification(Frame* frame, AXLoadingEvent l frameLoadingEventPlatformNotification(obj, loadingEvent); } +void AXObjectCache::postLiveRegionChangeNotification(AccessibilityObject* object) +{ + if (m_liveRegionChangedPostTimer.isActive()) + m_liveRegionChangedPostTimer.stop(); + + if (!m_liveRegionObjectsSet.contains(object)) + m_liveRegionObjectsSet.add(object); + + m_liveRegionChangedPostTimer.startOneShot(AccessibilityLiveRegionChangedNotificationInterval); +} + +void AXObjectCache::liveRegionChangedNotificationPostTimerFired() +{ + m_liveRegionChangedPostTimer.stop(); + + if (m_liveRegionObjectsSet.isEmpty()) + return; + + for (auto& object : m_liveRegionObjectsSet) + postNotification(object.get(), object->document(), AXObjectCache::AXLiveRegionChanged); + m_liveRegionObjectsSet.clear(); +} + +static AccessibilityObject* firstFocusableChild(AccessibilityObject* obj) +{ + if (!obj) + return nullptr; + + for (auto* child = obj->firstChild(); child; child = child->nextSibling()) { + if (child->canSetFocusAttribute()) + return child; + if (AccessibilityObject* focusable = firstFocusableChild(child)) + return focusable; + } + return nullptr; +} + +void AXObjectCache::focusAriaModalNode() +{ + if (m_focusAriaModalNodeTimer.isActive()) + m_focusAriaModalNodeTimer.stop(); + + m_focusAriaModalNodeTimer.startOneShot(AccessibilityFocusAriaModalNodeNotificationInterval); +} + +void AXObjectCache::focusAriaModalNodeTimerFired() +{ + if (!m_currentAriaModalNode) + return; + + // Don't set focus if we are already focusing onto some element within + // the dialog. + if (m_currentAriaModalNode->contains(document().focusedElement())) + return; + + if (AccessibilityObject* currentAriaModalNodeObject = getOrCreate(m_currentAriaModalNode)) { + if (AccessibilityObject* focusable = firstFocusableChild(currentAriaModalNodeObject)) + focusable->setFocused(true); + } +} + void AXObjectCache::handleScrollbarUpdate(ScrollView* view) { if (!view) @@ -874,7 +1431,7 @@ void AXObjectCache::handleAttributeChanged(const QualifiedName& attrName, Elemen handleAriaRoleChanged(element); else if (attrName == altAttr || attrName == titleAttr) textChanged(element); - else if (attrName == forAttr && isHTMLLabelElement(element)) + else if (attrName == forAttr && is<HTMLLabelElement>(*element)) labelChanged(element); if (!attrName.localName().string().startsWith("aria-")) @@ -895,17 +1452,44 @@ void AXObjectCache::handleAttributeChanged(const QualifiedName& attrName, Elemen else if (attrName == aria_expandedAttr) handleAriaExpandedChange(element); else if (attrName == aria_hiddenAttr) - childrenChanged(element->parentNode()); + childrenChanged(element->parentNode(), element); else if (attrName == aria_invalidAttr) postNotification(element, AXObjectCache::AXInvalidStatusChanged); + else if (attrName == aria_modalAttr) + handleAriaModalChange(element); else postNotification(element, AXObjectCache::AXAriaAttributeChanged); } +void AXObjectCache::handleAriaModalChange(Node* node) +{ + if (!is<Element>(node)) + return; + + if (!nodeHasRole(node, "dialog") && !nodeHasRole(node, "alertdialog")) + return; + + stopCachingComputedObjectAttributes(); + if (equalLettersIgnoringASCIICase(downcast<Element>(*node).attributeWithoutSynchronization(aria_modalAttr), "true")) { + // Add the newly modified node to the modal nodes set, and set it to be the current valid aria modal node. + // We will recompute the current valid aria modal node in ariaModalNode() when this node is not visible. + m_ariaModalNodesSet.add(node); + m_currentAriaModalNode = node; + } else { + // Remove the node from the modal nodes set. There might be other visible modal nodes, so we recompute here. + m_ariaModalNodesSet.remove(node); + updateCurrentAriaModalNode(); + } + if (m_currentAriaModalNode) + focusAriaModalNode(); + + startCachingComputedObjectAttributesUntilTreeMutates(); +} + void AXObjectCache::labelChanged(Element* element) { - ASSERT(isHTMLLabelElement(element)); - HTMLElement* correspondingControl = toHTMLLabelElement(element)->control(); + ASSERT(is<HTMLLabelElement>(*element)); + HTMLElement* correspondingControl = downcast<HTMLLabelElement>(*element).control(); textChanged(correspondingControl); } @@ -944,11 +1528,555 @@ VisiblePosition AXObjectCache::visiblePositionForTextMarkerData(TextMarkerData& AXObjectCache* cache = renderer->document().axObjectCache(); if (!cache->isIDinUse(textMarkerData.axID)) return VisiblePosition(); + + return visiblePos; +} + +CharacterOffset AXObjectCache::characterOffsetForTextMarkerData(TextMarkerData& textMarkerData) +{ + if (!isNodeInUse(textMarkerData.node)) + return CharacterOffset(); + + if (textMarkerData.ignored) + return CharacterOffset(); + + CharacterOffset result = CharacterOffset(textMarkerData.node, textMarkerData.characterStartIndex, textMarkerData.characterOffset); + // When we are at a line wrap and the VisiblePosition is upstream, it means the text marker is at the end of the previous line. + // We use the previous CharacterOffset so that it will match the Range. + if (textMarkerData.affinity == UPSTREAM) + return previousCharacterOffset(result, false); + return result; +} + +CharacterOffset AXObjectCache::traverseToOffsetInRange(RefPtr<Range>range, int offset, TraverseOption option, bool stayWithinRange) +{ + if (!range) + return CharacterOffset(); + + bool toNodeEnd = option & TraverseOptionToNodeEnd; + bool validateOffset = option & TraverseOptionValidateOffset; + + int offsetInCharacter = 0; + int cumulativeOffset = 0; + int remaining = 0; + int lastLength = 0; + Node* currentNode = nullptr; + bool finished = false; + int lastStartOffset = 0; + + TextIterator iterator(range.get(), TextIteratorEntersTextControls); + + // When the range has zero length, there might be replaced node or brTag that we need to increment the characterOffset. + if (iterator.atEnd()) { + currentNode = &range->startContainer(); + lastStartOffset = range->startOffset(); + if (offset > 0 || toNodeEnd) { + if (AccessibilityObject::replacedNodeNeedsCharacter(currentNode) || (currentNode->renderer() && currentNode->renderer()->isBR())) + cumulativeOffset++; + lastLength = cumulativeOffset; + + // When going backwards, stayWithinRange is false. + // Here when we don't have any character to move and we are going backwards, we traverse to the previous node. + if (!lastLength && toNodeEnd && !stayWithinRange) { + if (Node* preNode = previousNode(currentNode)) + return traverseToOffsetInRange(rangeForNodeContents(preNode), offset, option); + return CharacterOffset(); + } + } + } + + // Sometimes text contents in a node are splitted into several iterations, so that iterator.range()->startOffset() + // might not be the correct character count. Here we use a previousNode object to keep track of that. + Node* previousNode = nullptr; + for (; !iterator.atEnd(); iterator.advance()) { + int currentLength = iterator.text().length(); + bool hasReplacedNodeOrBR = false; + + Node& node = iterator.range()->startContainer(); + currentNode = &node; + + // When currentLength == 0, we check if there's any replaced node. + // If not, we skip the node with no length. + if (!currentLength) { + int subOffset = iterator.range()->startOffset(); + Node* childNode = node.traverseToChildAt(subOffset); + if (AccessibilityObject::replacedNodeNeedsCharacter(childNode)) { + cumulativeOffset++; + currentLength++; + currentNode = childNode; + hasReplacedNodeOrBR = true; + } else + continue; + } else { + // Ignore space, new line, tag node. + if (currentLength == 1) { + if (isSpaceOrNewline(iterator.text()[0])) { + // If the node has BR tag, we want to set the currentNode to it. + int subOffset = iterator.range()->startOffset(); + Node* childNode = node.traverseToChildAt(subOffset); + if (childNode && childNode->renderer() && childNode->renderer()->isBR()) { + currentNode = childNode; + hasReplacedNodeOrBR = true; + } else if (currentNode->isShadowRoot()) { + // Since we are entering text controls, we should set the currentNode + // to be the shadow host when there's no content. + currentNode = currentNode->shadowHost(); + continue; + } else if (currentNode != previousNode) { + // We should set the start offset and length for the current node in case this is the last iteration. + lastStartOffset = 1; + lastLength = 0; + continue; + } + } + } + cumulativeOffset += currentLength; + } + + if (currentNode == previousNode) { + lastLength += currentLength; + lastStartOffset = iterator.range()->endOffset() - lastLength; + } + else { + lastLength = currentLength; + lastStartOffset = hasReplacedNodeOrBR ? 0 : iterator.range()->startOffset(); + } + + // Break early if we have advanced enough characters. + bool offsetLimitReached = validateOffset ? cumulativeOffset + lastStartOffset >= offset : cumulativeOffset >= offset; + if (!toNodeEnd && offsetLimitReached) { + offsetInCharacter = validateOffset ? std::max(offset - lastStartOffset, 0) : offset - (cumulativeOffset - lastLength); + finished = true; + break; + } + previousNode = currentNode; + } + + if (!finished) { + offsetInCharacter = lastLength; + if (!toNodeEnd) + remaining = offset - cumulativeOffset; + } + + // Sometimes when we are getting the end CharacterOffset of a line range, the TextIterator will emit an extra space at the end + // and make the character count greater than the Range's end offset. + if (toNodeEnd && currentNode->isTextNode() && currentNode == &range->endContainer() && static_cast<int>(range->endOffset()) < lastStartOffset + offsetInCharacter) + offsetInCharacter = range->endOffset() - lastStartOffset; + + return CharacterOffset(currentNode, lastStartOffset, offsetInCharacter, remaining); +} + +int AXObjectCache::lengthForRange(Range* range) +{ + if (!range) + return -1; + + int length = 0; + for (TextIterator it(range); !it.atEnd(); it.advance()) { + // non-zero length means textual node, zero length means replaced node (AKA "attachments" in AX) + if (it.text().length()) + length += it.text().length(); + else { + // locate the node and starting offset for this replaced range + Node& node = it.range()->startContainer(); + int offset = it.range()->startOffset(); + if (AccessibilityObject::replacedNodeNeedsCharacter(node.traverseToChildAt(offset))) + ++length; + } + } + + return length; +} + +RefPtr<Range> AXObjectCache::rangeForNodeContents(Node* node) +{ + if (!node) + return nullptr; + Document* document = &node->document(); + if (!document) + return nullptr; + auto range = Range::create(*document); + if (AccessibilityObject::replacedNodeNeedsCharacter(node)) { + // For replaced nodes without children, the node itself is included in the range. + if (range->selectNode(*node).hasException()) + return nullptr; + } else { + if (range->selectNodeContents(*node).hasException()) + return nullptr; + } + return WTFMove(range); +} + +static bool isReplacedNodeOrBR(Node* node) +{ + return node && (AccessibilityObject::replacedNodeNeedsCharacter(node) || node->hasTagName(brTag)); +} + +static bool characterOffsetsInOrder(const CharacterOffset& characterOffset1, const CharacterOffset& characterOffset2) +{ + if (characterOffset1.isNull() || characterOffset2.isNull()) + return false; + + if (characterOffset1.node == characterOffset2.node) + return characterOffset1.offset <= characterOffset2.offset; + + Node* node1 = characterOffset1.node; + Node* node2 = characterOffset2.node; + if (!node1->offsetInCharacters() && !isReplacedNodeOrBR(node1) && node1->hasChildNodes()) + node1 = node1->traverseToChildAt(characterOffset1.offset); + if (!node2->offsetInCharacters() && !isReplacedNodeOrBR(node2) && node2->hasChildNodes()) + node2 = node2->traverseToChildAt(characterOffset2.offset); + + if (!node1 || !node2) + return false; + + RefPtr<Range> range1 = AXObjectCache::rangeForNodeContents(node1); + RefPtr<Range> range2 = AXObjectCache::rangeForNodeContents(node2); + + if (!range2) + return true; + if (!range1) + return false; + auto result = range1->compareBoundaryPoints(Range::START_TO_START, *range2); + if (result.hasException()) + return true; + return result.releaseReturnValue() <= 0; +} + +static Node* resetNodeAndOffsetForReplacedNode(Node* replacedNode, int& offset, int characterCount) +{ + // Use this function to include the replaced node itself in the range we are creating. + if (!replacedNode) + return nullptr; + + RefPtr<Range> nodeRange = AXObjectCache::rangeForNodeContents(replacedNode); + int nodeLength = TextIterator::rangeLength(nodeRange.get()); + offset = characterCount <= nodeLength ? replacedNode->computeNodeIndex() : replacedNode->computeNodeIndex() + 1; + return replacedNode->parentNode(); +} + +static bool setRangeStartOrEndWithCharacterOffset(Range& range, const CharacterOffset& characterOffset, bool isStart) +{ + if (characterOffset.isNull()) + return false; + + int offset = characterOffset.startIndex + characterOffset.offset; + Node* node = characterOffset.node; + ASSERT(node); + + bool replacedNodeOrBR = isReplacedNodeOrBR(node); + // For the non text node that has no children, we should create the range with its parent, otherwise the range would be collapsed. + // Example: <div contenteditable="true"></div>, we want the range to include the div element. + bool noChildren = !replacedNodeOrBR && !node->isTextNode() && !node->hasChildNodes(); + int characterCount = noChildren ? (isStart ? 0 : 1) : characterOffset.offset; - if (deepPos.deprecatedNode() != textMarkerData.node || deepPos.deprecatedEditingOffset() != textMarkerData.offset) + if (replacedNodeOrBR || noChildren) + node = resetNodeAndOffsetForReplacedNode(node, offset, characterCount); + + if (!node) + return false; + + if (isStart) { + if (range.setStart(*node, offset).hasException()) + return false; + } else { + if (range.setEnd(*node, offset).hasException()) + return false; + } + + return true; +} + +RefPtr<Range> AXObjectCache::rangeForUnorderedCharacterOffsets(const CharacterOffset& characterOffset1, const CharacterOffset& characterOffset2) +{ + if (characterOffset1.isNull() || characterOffset2.isNull()) + return nullptr; + + bool alreadyInOrder = characterOffsetsInOrder(characterOffset1, characterOffset2); + CharacterOffset startCharacterOffset = alreadyInOrder ? characterOffset1 : characterOffset2; + CharacterOffset endCharacterOffset = alreadyInOrder ? characterOffset2 : characterOffset1; + + auto result = Range::create(m_document); + if (!setRangeStartOrEndWithCharacterOffset(result, startCharacterOffset, true)) + return nullptr; + if (!setRangeStartOrEndWithCharacterOffset(result, endCharacterOffset, false)) + return nullptr; + return WTFMove(result); +} + +void AXObjectCache::setTextMarkerDataWithCharacterOffset(TextMarkerData& textMarkerData, const CharacterOffset& characterOffset) +{ + if (characterOffset.isNull()) + return; + + Node* domNode = characterOffset.node; + if (is<HTMLInputElement>(*domNode) && downcast<HTMLInputElement>(*domNode).isPasswordField()) { + textMarkerData.ignored = true; + return; + } + + RefPtr<AccessibilityObject> obj = this->getOrCreate(domNode); + if (!obj) + return; + + // Convert to visible position. + VisiblePosition visiblePosition = visiblePositionFromCharacterOffset(characterOffset); + int vpOffset = 0; + if (!visiblePosition.isNull()) { + Position deepPos = visiblePosition.deepEquivalent(); + vpOffset = deepPos.deprecatedEditingOffset(); + } + + textMarkerData.axID = obj.get()->axObjectID(); + textMarkerData.node = domNode; + textMarkerData.characterOffset = characterOffset.offset; + textMarkerData.characterStartIndex = characterOffset.startIndex; + textMarkerData.offset = vpOffset; + textMarkerData.affinity = visiblePosition.affinity(); + + this->setNodeInUse(domNode); +} + +CharacterOffset AXObjectCache::startOrEndCharacterOffsetForRange(RefPtr<Range> range, bool isStart) +{ + if (!range) + return CharacterOffset(); + + // When getting the end CharacterOffset at node boundary, we don't want to collapse to the previous node. + if (!isStart && !range->endOffset()) + return characterOffsetForNodeAndOffset(range->endContainer(), 0, TraverseOptionIncludeStart); + + // If it's end text marker, we want to go to the end of the range, and stay within the range. + bool stayWithinRange = !isStart; + + Node& endNode = range->endContainer(); + if (endNode.offsetInCharacters() && !isStart) + return traverseToOffsetInRange(rangeForNodeContents(&endNode), range->endOffset(), TraverseOptionValidateOffset); + + Ref<Range> copyRange = *range; + // Change the start of the range, so the character offset starts from node beginning. + int offset = 0; + Node& node = copyRange->startContainer(); + if (node.offsetInCharacters()) { + CharacterOffset nodeStartOffset = traverseToOffsetInRange(rangeForNodeContents(&node), range->startOffset(), TraverseOptionValidateOffset); + if (isStart) + return nodeStartOffset; + copyRange = Range::create(range->ownerDocument(), &range->startContainer(), 0, &range->endContainer(), range->endOffset()); + offset += nodeStartOffset.offset; + } + + return traverseToOffsetInRange(WTFMove(copyRange), offset, isStart ? TraverseOptionDefault : TraverseOptionToNodeEnd, stayWithinRange); +} + +void AXObjectCache::startOrEndTextMarkerDataForRange(TextMarkerData& textMarkerData, RefPtr<Range> range, bool isStart) +{ + memset(&textMarkerData, 0, sizeof(TextMarkerData)); + + CharacterOffset characterOffset = startOrEndCharacterOffsetForRange(range, isStart); + if (characterOffset.isNull()) + return; + + setTextMarkerDataWithCharacterOffset(textMarkerData, characterOffset); +} + +CharacterOffset AXObjectCache::characterOffsetForNodeAndOffset(Node& node, int offset, TraverseOption option) +{ + Node* domNode = &node; + if (!domNode) + return CharacterOffset(); + + bool toNodeEnd = option & TraverseOptionToNodeEnd; + bool includeStart = option & TraverseOptionIncludeStart; + + // ignoreStart is used to determine if we should go to previous node or + // stay in current node when offset is 0. + if (!toNodeEnd && (offset < 0 || (!offset && !includeStart))) { + // Set the offset to the amount of characters we need to go backwards. + offset = - offset; + CharacterOffset charOffset = CharacterOffset(); + while (offset >= 0 && charOffset.offset <= offset) { + offset -= charOffset.offset; + domNode = previousNode(domNode); + if (domNode) { + charOffset = characterOffsetForNodeAndOffset(*domNode, 0, TraverseOptionToNodeEnd); + } else + return CharacterOffset(); + if (charOffset.offset == offset) + break; + } + if (offset > 0) + charOffset = characterOffsetForNodeAndOffset(*charOffset.node, charOffset.offset - offset, TraverseOptionIncludeStart); + return charOffset; + } + + RefPtr<Range> range = rangeForNodeContents(domNode); + + // Traverse the offset amount of characters forward and see if there's remaining offsets. + // Keep traversing to the next node when there's remaining offsets. + CharacterOffset characterOffset = traverseToOffsetInRange(range, offset, option); + while (!characterOffset.isNull() && characterOffset.remaining() && !toNodeEnd) { + domNode = nextNode(domNode); + if (!domNode) + return CharacterOffset(); + range = rangeForNodeContents(domNode); + characterOffset = traverseToOffsetInRange(range, characterOffset.remaining(), option); + } + + return characterOffset; +} + +void AXObjectCache::textMarkerDataForCharacterOffset(TextMarkerData& textMarkerData, const CharacterOffset& characterOffset) +{ + memset(&textMarkerData, 0, sizeof(TextMarkerData)); + setTextMarkerDataWithCharacterOffset(textMarkerData, characterOffset); +} + +bool AXObjectCache::shouldSkipBoundary(const CharacterOffset& previous, const CharacterOffset& next) +{ + // Match the behavior of VisiblePosition, we should skip the node boundary when there's no visual space or new line character. + if (previous.isNull() || next.isNull()) + return false; + + if (previous.node == next.node) + return false; + + if (next.startIndex > 0 || next.offset > 0) + return false; + + CharacterOffset newLine = startCharacterOffsetOfLine(next); + if (next.isEqual(newLine)) + return false; + + return true; +} + +void AXObjectCache::textMarkerDataForNextCharacterOffset(TextMarkerData& textMarkerData, const CharacterOffset& characterOffset) +{ + CharacterOffset next = characterOffset; + CharacterOffset previous = characterOffset; + bool shouldContinue; + do { + shouldContinue = false; + next = nextCharacterOffset(next, false); + if (shouldSkipBoundary(previous, next)) + next = nextCharacterOffset(next, false); + textMarkerDataForCharacterOffset(textMarkerData, next); + + // We should skip next CharactetOffset if it's visually the same. + if (!lengthForRange(rangeForUnorderedCharacterOffsets(previous, next).get())) + shouldContinue = true; + previous = next; + } while (textMarkerData.ignored || shouldContinue); +} + +void AXObjectCache::textMarkerDataForPreviousCharacterOffset(TextMarkerData& textMarkerData, const CharacterOffset& characterOffset) +{ + CharacterOffset previous = characterOffset; + CharacterOffset next = characterOffset; + bool shouldContinue; + do { + shouldContinue = false; + previous = previousCharacterOffset(previous, false); + textMarkerDataForCharacterOffset(textMarkerData, previous); + + // We should skip previous CharactetOffset if it's visually the same. + if (!lengthForRange(rangeForUnorderedCharacterOffsets(previous, next).get())) + shouldContinue = true; + next = previous; + } while (textMarkerData.ignored || shouldContinue); +} + +Node* AXObjectCache::nextNode(Node* node) const +{ + if (!node) + return nullptr; + + return NodeTraversal::nextSkippingChildren(*node); +} + +Node* AXObjectCache::previousNode(Node* node) const +{ + if (!node) + return nullptr; + + // First child of body shouldn't have previous node. + if (node->parentNode() && node->parentNode()->renderer() && node->parentNode()->renderer()->isBody() && !node->previousSibling()) + return nullptr; + + return NodeTraversal::previousSkippingChildren(*node); +} + +VisiblePosition AXObjectCache::visiblePositionFromCharacterOffset(const CharacterOffset& characterOffset) +{ + if (characterOffset.isNull()) return VisiblePosition(); - return visiblePos; + // Create a collapsed range and use that to form a VisiblePosition, so that the case with + // composed characters will be covered. + auto range = rangeForUnorderedCharacterOffsets(characterOffset, characterOffset); + return range ? VisiblePosition(range->startPosition()) : VisiblePosition(); +} + +CharacterOffset AXObjectCache::characterOffsetFromVisiblePosition(const VisiblePosition& visiblePos) +{ + if (visiblePos.isNull()) + return CharacterOffset(); + + Position deepPos = visiblePos.deepEquivalent(); + Node* domNode = deepPos.deprecatedNode(); + ASSERT(domNode); + + if (domNode->offsetInCharacters()) + return traverseToOffsetInRange(rangeForNodeContents(domNode), deepPos.deprecatedEditingOffset(), TraverseOptionValidateOffset); + + RefPtr<AccessibilityObject> obj = this->getOrCreate(domNode); + if (!obj) + return CharacterOffset(); + + // Use nextVisiblePosition to calculate how many characters we need to traverse to the current position. + VisiblePositionRange visiblePositionRange = obj->visiblePositionRange(); + VisiblePosition visiblePosition = visiblePositionRange.start; + int characterOffset = 0; + Position currentPosition = visiblePosition.deepEquivalent(); + + VisiblePosition previousVisiblePos; + while (!currentPosition.isNull() && !deepPos.equals(currentPosition)) { + previousVisiblePos = visiblePosition; + visiblePosition = obj->nextVisiblePosition(visiblePosition); + currentPosition = visiblePosition.deepEquivalent(); + Position previousPosition = previousVisiblePos.deepEquivalent(); + // Sometimes nextVisiblePosition will give the same VisiblePostion, + // we break here to avoid infinite loop. + if (currentPosition.equals(previousPosition)) + break; + characterOffset++; + + // When VisiblePostion moves to next node, it will count the leading line break as + // 1 offset, which we shouldn't include in CharacterOffset. + if (currentPosition.deprecatedNode() != previousPosition.deprecatedNode()) { + if (visiblePosition.characterBefore() == '\n') + characterOffset--; + } else { + // Sometimes VisiblePosition will move multiple characters, like emoji. + if (currentPosition.deprecatedNode()->offsetInCharacters()) + characterOffset += currentPosition.offsetInContainerNode() - previousPosition.offsetInContainerNode() - 1; + } + } + + // Sometimes when the node is a replaced node and is ignored in accessibility, we get a wrong CharacterOffset from it. + CharacterOffset result = traverseToOffsetInRange(rangeForNodeContents(obj->node()), characterOffset); + if (result.remainingOffset > 0 && !result.isNull() && isRendererReplacedElement(result.node->renderer())) + result.offset += result.remainingOffset; + return result; +} + +AccessibilityObject* AXObjectCache::accessibilityObjectForTextMarkerData(TextMarkerData& textMarkerData) +{ + if (!isNodeInUse(textMarkerData.node)) + return nullptr; + + Node* domNode = textMarkerData.node; + return this->getOrCreate(domNode); } void AXObjectCache::textMarkerDataForVisiblePosition(TextMarkerData& textMarkerData, const VisiblePosition& visiblePos) @@ -966,10 +2094,15 @@ void AXObjectCache::textMarkerDataForVisiblePosition(TextMarkerData& textMarkerD if (!domNode) return; - if (domNode->isHTMLElement()) { - HTMLInputElement* inputElement = domNode->toInputElement(); - if (inputElement && inputElement->isPasswordField()) - return; + if (is<HTMLInputElement>(*domNode) && downcast<HTMLInputElement>(*domNode).isPasswordField()) + return; + + // If the visible position has an anchor type referring to a node other than the anchored node, we should + // set the text marker data with CharacterOffset so that the offset will correspond to the node. + CharacterOffset characterOffset = characterOffsetFromVisiblePosition(visiblePos); + if (deepPos.anchorType() == Position::PositionIsAfterAnchor || deepPos.anchorType() == Position::PositionIsAfterChildren) { + textMarkerDataForCharacterOffset(textMarkerData, characterOffset); + return; } // find or create an accessibility object for this node @@ -979,15 +2112,535 @@ void AXObjectCache::textMarkerDataForVisiblePosition(TextMarkerData& textMarkerD textMarkerData.axID = obj.get()->axObjectID(); textMarkerData.node = domNode; textMarkerData.offset = deepPos.deprecatedEditingOffset(); - textMarkerData.affinity = visiblePos.affinity(); + textMarkerData.affinity = visiblePos.affinity(); + + textMarkerData.characterOffset = characterOffset.offset; + textMarkerData.characterStartIndex = characterOffset.startIndex; cache->setNodeInUse(domNode); } +CharacterOffset AXObjectCache::nextCharacterOffset(const CharacterOffset& characterOffset, bool ignoreNextNodeStart) +{ + if (characterOffset.isNull()) + return CharacterOffset(); + + // We don't always move one 'character' at a time since there might be composed characters. + int nextOffset = Position::uncheckedNextOffset(characterOffset.node, characterOffset.offset); + CharacterOffset next = characterOffsetForNodeAndOffset(*characterOffset.node, nextOffset); + + // To be consistent with VisiblePosition, we should consider the case that current node end to next node start counts 1 offset. + if (!ignoreNextNodeStart && !next.isNull() && !isReplacedNodeOrBR(next.node) && next.node != characterOffset.node) { + int length = TextIterator::rangeLength(rangeForUnorderedCharacterOffsets(characterOffset, next).get()); + if (length > nextOffset - characterOffset.offset) + next = characterOffsetForNodeAndOffset(*next.node, 0, TraverseOptionIncludeStart); + } + + return next; +} + +CharacterOffset AXObjectCache::previousCharacterOffset(const CharacterOffset& characterOffset, bool ignorePreviousNodeEnd) +{ + if (characterOffset.isNull()) + return CharacterOffset(); + + // To be consistent with VisiblePosition, we should consider the case that current node start to previous node end counts 1 offset. + if (!ignorePreviousNodeEnd && !characterOffset.offset) + return characterOffsetForNodeAndOffset(*characterOffset.node, 0); + + // We don't always move one 'character' a time since there might be composed characters. + int previousOffset = Position::uncheckedPreviousOffset(characterOffset.node, characterOffset.offset); + return characterOffsetForNodeAndOffset(*characterOffset.node, previousOffset, TraverseOptionIncludeStart); +} + +CharacterOffset AXObjectCache::startCharacterOffsetOfWord(const CharacterOffset& characterOffset, EWordSide side) +{ + if (characterOffset.isNull()) + return CharacterOffset(); + + CharacterOffset c = characterOffset; + if (side == RightWordIfOnBoundary) { + CharacterOffset endOfParagraph = endCharacterOffsetOfParagraph(c); + if (c.isEqual(endOfParagraph)) + return c; + + // We should consider the node boundary that splits words. Otherwise VoiceOver won't see it as space. + c = nextCharacterOffset(characterOffset, false); + if (shouldSkipBoundary(characterOffset, c)) + c = nextCharacterOffset(c, false); + if (c.isNull()) + return characterOffset; + } + + return previousBoundary(c, startWordBoundary); +} + +CharacterOffset AXObjectCache::endCharacterOffsetOfWord(const CharacterOffset& characterOffset, EWordSide side) +{ + if (characterOffset.isNull()) + return CharacterOffset(); + + CharacterOffset c = characterOffset; + if (side == LeftWordIfOnBoundary) { + CharacterOffset startOfParagraph = startCharacterOffsetOfParagraph(c); + if (c.isEqual(startOfParagraph)) + return c; + + c = previousCharacterOffset(characterOffset); + if (c.isNull()) + return characterOffset; + } else { + CharacterOffset endOfParagraph = endCharacterOffsetOfParagraph(characterOffset); + if (characterOffset.isEqual(endOfParagraph)) + return characterOffset; + } + + return nextBoundary(c, endWordBoundary); +} + +CharacterOffset AXObjectCache::previousWordStartCharacterOffset(const CharacterOffset& characterOffset) +{ + if (characterOffset.isNull()) + return CharacterOffset(); + + CharacterOffset previousOffset = previousCharacterOffset(characterOffset); + if (previousOffset.isNull()) + return CharacterOffset(); + + return startCharacterOffsetOfWord(previousOffset, RightWordIfOnBoundary); +} + +CharacterOffset AXObjectCache::nextWordEndCharacterOffset(const CharacterOffset& characterOffset) +{ + if (characterOffset.isNull()) + return CharacterOffset(); + + CharacterOffset nextOffset = nextCharacterOffset(characterOffset); + if (nextOffset.isNull()) + return CharacterOffset(); + + return endCharacterOffsetOfWord(nextOffset, LeftWordIfOnBoundary); +} + +RefPtr<Range> AXObjectCache::leftWordRange(const CharacterOffset& characterOffset) +{ + CharacterOffset start = startCharacterOffsetOfWord(characterOffset, LeftWordIfOnBoundary); + CharacterOffset end = endCharacterOffsetOfWord(start); + return rangeForUnorderedCharacterOffsets(start, end); +} + +RefPtr<Range> AXObjectCache::rightWordRange(const CharacterOffset& characterOffset) +{ + CharacterOffset start = startCharacterOffsetOfWord(characterOffset, RightWordIfOnBoundary); + CharacterOffset end = endCharacterOffsetOfWord(start); + return rangeForUnorderedCharacterOffsets(start, end); +} + +static UChar32 characterForCharacterOffset(const CharacterOffset& characterOffset) +{ + if (characterOffset.isNull() || !characterOffset.node->isTextNode()) + return 0; + + UChar32 ch = 0; + unsigned offset = characterOffset.startIndex + characterOffset.offset; + if (offset < characterOffset.node->textContent().length()) + U16_NEXT(characterOffset.node->textContent(), offset, characterOffset.node->textContent().length(), ch); + return ch; +} + +UChar32 AXObjectCache::characterAfter(const CharacterOffset& characterOffset) +{ + return characterForCharacterOffset(nextCharacterOffset(characterOffset)); +} + +UChar32 AXObjectCache::characterBefore(const CharacterOffset& characterOffset) +{ + return characterForCharacterOffset(characterOffset); +} + +static bool characterOffsetNodeIsBR(const CharacterOffset& characterOffset) +{ + if (characterOffset.isNull()) + return false; + + return characterOffset.node->hasTagName(brTag); +} + +static Node* parentEditingBoundary(Node* node) +{ + if (!node) + return nullptr; + + Node* documentElement = node->document().documentElement(); + if (!documentElement) + return nullptr; + + Node* boundary = node; + while (boundary != documentElement && boundary->nonShadowBoundaryParentNode() && node->hasEditableStyle() == boundary->parentNode()->hasEditableStyle()) + boundary = boundary->nonShadowBoundaryParentNode(); + + return boundary; +} + +CharacterOffset AXObjectCache::nextBoundary(const CharacterOffset& characterOffset, BoundarySearchFunction searchFunction) +{ + if (characterOffset.isNull()) + return { }; + + Node* boundary = parentEditingBoundary(characterOffset.node); + if (!boundary) + return { }; + + RefPtr<Range> searchRange = rangeForNodeContents(boundary); + if (!searchRange) + return { }; + + Vector<UChar, 1024> string; + unsigned prefixLength = 0; + + if (requiresContextForWordBoundary(characterAfter(characterOffset))) { + auto backwardsScanRange = boundary->document().createRange(); + if (!setRangeStartOrEndWithCharacterOffset(backwardsScanRange, characterOffset, false)) + return { }; + prefixLength = prefixLengthForRange(backwardsScanRange, string); + } + + if (!setRangeStartOrEndWithCharacterOffset(*searchRange, characterOffset, true)) + return { }; + CharacterOffset end = startOrEndCharacterOffsetForRange(searchRange, false); + + TextIterator it(searchRange.get(), TextIteratorEmitsObjectReplacementCharacters); + unsigned next = forwardSearchForBoundaryWithTextIterator(it, string, prefixLength, searchFunction); + + if (it.atEnd() && next == string.size()) + return end; + + // We should consider the node boundary that splits words. + if (searchFunction == endWordBoundary && next - prefixLength == 1) + return nextCharacterOffset(characterOffset, false); + + // The endSentenceBoundary function will include a line break at the end of the sentence. + if (searchFunction == endSentenceBoundary && string[next - 1] == '\n') + next--; + + if (next > prefixLength) + return characterOffsetForNodeAndOffset(*characterOffset.node, characterOffset.offset + next - prefixLength); + + return characterOffset; +} + +CharacterOffset AXObjectCache::previousBoundary(const CharacterOffset& characterOffset, BoundarySearchFunction searchFunction) +{ + if (characterOffset.isNull()) + return CharacterOffset(); + + Node* boundary = parentEditingBoundary(characterOffset.node); + if (!boundary) + return CharacterOffset(); + + RefPtr<Range> searchRange = rangeForNodeContents(boundary); + Vector<UChar, 1024> string; + unsigned suffixLength = 0; + + if (requiresContextForWordBoundary(characterBefore(characterOffset))) { + auto forwardsScanRange = boundary->document().createRange(); + if (forwardsScanRange->setEndAfter(*boundary).hasException()) + return { }; + if (!setRangeStartOrEndWithCharacterOffset(forwardsScanRange, characterOffset, true)) + return { }; + suffixLength = suffixLengthForRange(forwardsScanRange, string); + } + + if (!setRangeStartOrEndWithCharacterOffset(*searchRange, characterOffset, false)) + return { }; + CharacterOffset start = startOrEndCharacterOffsetForRange(searchRange, true); + + SimplifiedBackwardsTextIterator it(*searchRange); + unsigned next = backwardSearchForBoundaryWithTextIterator(it, string, suffixLength, searchFunction); + + if (!next) + return it.atEnd() ? start : characterOffset; + + Node& node = it.atEnd() ? searchRange->startContainer() : it.range()->startContainer(); + + // SimplifiedBackwardsTextIterator ignores replaced elements. + if (AccessibilityObject::replacedNodeNeedsCharacter(characterOffset.node)) + return characterOffsetForNodeAndOffset(*characterOffset.node, 0); + Node* nextSibling = node.nextSibling(); + if (&node != characterOffset.node && AccessibilityObject::replacedNodeNeedsCharacter(nextSibling)) + return startOrEndCharacterOffsetForRange(rangeForNodeContents(nextSibling), false); + + if ((node.isTextNode() && static_cast<int>(next) <= node.maxCharacterOffset()) || (node.renderer() && node.renderer()->isBR() && !next)) { + // The next variable contains a usable index into a text node + if (node.isTextNode()) + return traverseToOffsetInRange(rangeForNodeContents(&node), next, TraverseOptionValidateOffset); + return characterOffsetForNodeAndOffset(node, next, TraverseOptionIncludeStart); + } + + int characterCount = characterOffset.offset - (string.size() - suffixLength - next); + // We don't want to go to the previous node if the node is at the start of a new line. + if (characterCount < 0 && (characterOffsetNodeIsBR(characterOffset) || string[string.size() - suffixLength - 1] == '\n')) + characterCount = 0; + return characterOffsetForNodeAndOffset(*characterOffset.node, characterCount, TraverseOptionIncludeStart); +} + +CharacterOffset AXObjectCache::startCharacterOffsetOfParagraph(const CharacterOffset& characterOffset, EditingBoundaryCrossingRule boundaryCrossingRule) +{ + if (characterOffset.isNull()) + return CharacterOffset(); + + auto* startNode = characterOffset.node; + + if (isRenderedAsNonInlineTableImageOrHR(startNode)) + return startOrEndCharacterOffsetForRange(rangeForNodeContents(startNode), true); + + auto* startBlock = enclosingBlock(startNode); + int offset = characterOffset.startIndex + characterOffset.offset; + auto* highestRoot = highestEditableRoot(firstPositionInOrBeforeNode(startNode)); + Position::AnchorType type = Position::PositionIsOffsetInAnchor; + + auto* node = findStartOfParagraph(startNode, highestRoot, startBlock, offset, type, boundaryCrossingRule); + + if (type == Position::PositionIsOffsetInAnchor) + return characterOffsetForNodeAndOffset(*node, offset, TraverseOptionIncludeStart); + + return startOrEndCharacterOffsetForRange(rangeForNodeContents(node), true); +} + +CharacterOffset AXObjectCache::endCharacterOffsetOfParagraph(const CharacterOffset& characterOffset, EditingBoundaryCrossingRule boundaryCrossingRule) +{ + if (characterOffset.isNull()) + return CharacterOffset(); + + Node* startNode = characterOffset.node; + if (isRenderedAsNonInlineTableImageOrHR(startNode)) + return startOrEndCharacterOffsetForRange(rangeForNodeContents(startNode), false); + + Node* stayInsideBlock = enclosingBlock(startNode); + int offset = characterOffset.startIndex + characterOffset.offset; + Node* highestRoot = highestEditableRoot(firstPositionInOrBeforeNode(startNode)); + Position::AnchorType type = Position::PositionIsOffsetInAnchor; + + Node* node = findEndOfParagraph(startNode, highestRoot, stayInsideBlock, offset, type, boundaryCrossingRule); + if (type == Position::PositionIsOffsetInAnchor) { + if (node->isTextNode()) { + CharacterOffset startOffset = startOrEndCharacterOffsetForRange(rangeForNodeContents(node), true); + offset -= startOffset.startIndex; + } + return characterOffsetForNodeAndOffset(*node, offset, TraverseOptionIncludeStart); + } + + return startOrEndCharacterOffsetForRange(rangeForNodeContents(node), false); +} + +RefPtr<Range> AXObjectCache::paragraphForCharacterOffset(const CharacterOffset& characterOffset) +{ + CharacterOffset start = startCharacterOffsetOfParagraph(characterOffset); + CharacterOffset end = endCharacterOffsetOfParagraph(start); + + return rangeForUnorderedCharacterOffsets(start, end); +} + +CharacterOffset AXObjectCache::nextParagraphEndCharacterOffset(const CharacterOffset& characterOffset) +{ + // make sure we move off of a paragraph end + CharacterOffset next = nextCharacterOffset(characterOffset); + + // We should skip the following BR node. + if (characterOffsetNodeIsBR(next) && !characterOffsetNodeIsBR(characterOffset)) + next = nextCharacterOffset(next); + + return endCharacterOffsetOfParagraph(next); +} + +CharacterOffset AXObjectCache::previousParagraphStartCharacterOffset(const CharacterOffset& characterOffset) +{ + // make sure we move off of a paragraph start + CharacterOffset previous = previousCharacterOffset(characterOffset); + + // We should skip the preceding BR node. + if (characterOffsetNodeIsBR(previous) && !characterOffsetNodeIsBR(characterOffset)) + previous = previousCharacterOffset(previous); + + return startCharacterOffsetOfParagraph(previous); +} + +CharacterOffset AXObjectCache::startCharacterOffsetOfSentence(const CharacterOffset& characterOffset) +{ + return previousBoundary(characterOffset, startSentenceBoundary); +} + +CharacterOffset AXObjectCache::endCharacterOffsetOfSentence(const CharacterOffset& characterOffset) +{ + return nextBoundary(characterOffset, endSentenceBoundary); +} + +RefPtr<Range> AXObjectCache::sentenceForCharacterOffset(const CharacterOffset& characterOffset) +{ + CharacterOffset start = startCharacterOffsetOfSentence(characterOffset); + CharacterOffset end = endCharacterOffsetOfSentence(start); + return rangeForUnorderedCharacterOffsets(start, end); +} + +CharacterOffset AXObjectCache::nextSentenceEndCharacterOffset(const CharacterOffset& characterOffset) +{ + // Make sure we move off of a sentence end. + return endCharacterOffsetOfSentence(nextCharacterOffset(characterOffset)); +} + +CharacterOffset AXObjectCache::previousSentenceStartCharacterOffset(const CharacterOffset& characterOffset) +{ + // Make sure we move off of a sentence start. + CharacterOffset previous = previousCharacterOffset(characterOffset); + + // We should skip the preceding BR node. + if (characterOffsetNodeIsBR(previous) && !characterOffsetNodeIsBR(characterOffset)) + previous = previousCharacterOffset(previous); + + return startCharacterOffsetOfSentence(previous); +} + +LayoutRect AXObjectCache::localCaretRectForCharacterOffset(RenderObject*& renderer, const CharacterOffset& characterOffset) +{ + if (characterOffset.isNull()) { + renderer = nullptr; + return IntRect(); + } + + Node* node = characterOffset.node; + + renderer = node->renderer(); + if (!renderer) + return LayoutRect(); + + InlineBox* inlineBox = nullptr; + int caretOffset; + // Use a collapsed range to get the position. + RefPtr<Range> range = rangeForUnorderedCharacterOffsets(characterOffset, characterOffset); + if (!range) + return IntRect(); + + Position startPosition = range->startPosition(); + startPosition.getInlineBoxAndOffset(DOWNSTREAM, inlineBox, caretOffset); + + if (inlineBox) + renderer = &inlineBox->renderer(); + + if (is<RenderLineBreak>(renderer) && downcast<RenderLineBreak>(renderer)->inlineBoxWrapper() != inlineBox) + return IntRect(); + + return renderer->localCaretRect(inlineBox, caretOffset); +} + +IntRect AXObjectCache::absoluteCaretBoundsForCharacterOffset(const CharacterOffset& characterOffset) +{ + RenderBlock* caretPainter = nullptr; + + // First compute a rect local to the renderer at the selection start. + RenderObject* renderer = nullptr; + LayoutRect localRect = localCaretRectForCharacterOffset(renderer, characterOffset); + + localRect = localCaretRectInRendererForRect(localRect, characterOffset.node, renderer, caretPainter); + return absoluteBoundsForLocalCaretRect(caretPainter, localRect); +} + +CharacterOffset AXObjectCache::characterOffsetForPoint(const IntPoint &point, AccessibilityObject* obj) +{ + if (!obj) + return CharacterOffset(); + + VisiblePosition vp = obj->visiblePositionForPoint(point); + RefPtr<Range> range = makeRange(vp, vp); + return startOrEndCharacterOffsetForRange(range, true); +} + +CharacterOffset AXObjectCache::characterOffsetForPoint(const IntPoint &point) +{ + RefPtr<Range> caretRange = m_document.caretRangeFromPoint(LayoutPoint(point)); + return startOrEndCharacterOffsetForRange(caretRange, true); +} + +CharacterOffset AXObjectCache::characterOffsetForBounds(const IntRect& rect, bool first) +{ + if (rect.isEmpty()) + return CharacterOffset(); + + IntPoint corner = first ? rect.minXMinYCorner() : rect.maxXMaxYCorner(); + CharacterOffset characterOffset = characterOffsetForPoint(corner); + + if (rect.contains(absoluteCaretBoundsForCharacterOffset(characterOffset).center())) + return characterOffset; + + // If the initial position is located outside the bounds adjust it incrementally as needed. + CharacterOffset nextCharOffset = nextCharacterOffset(characterOffset, false); + CharacterOffset previousCharOffset = previousCharacterOffset(characterOffset, false); + while (!nextCharOffset.isNull() || !previousCharOffset.isNull()) { + if (rect.contains(absoluteCaretBoundsForCharacterOffset(nextCharOffset).center())) + return nextCharOffset; + if (rect.contains(absoluteCaretBoundsForCharacterOffset(previousCharOffset).center())) + return previousCharOffset; + + nextCharOffset = nextCharacterOffset(nextCharOffset, false); + previousCharOffset = previousCharacterOffset(previousCharOffset, false); + } + + return CharacterOffset(); +} + +// FIXME: Remove VisiblePosition code after implementing this using CharacterOffset. +CharacterOffset AXObjectCache::endCharacterOffsetOfLine(const CharacterOffset& characterOffset) +{ + if (characterOffset.isNull()) + return CharacterOffset(); + + VisiblePosition vp = visiblePositionFromCharacterOffset(characterOffset); + VisiblePosition endLine = endOfLine(vp); + + return characterOffsetFromVisiblePosition(endLine); +} + +CharacterOffset AXObjectCache::startCharacterOffsetOfLine(const CharacterOffset& characterOffset) +{ + if (characterOffset.isNull()) + return CharacterOffset(); + + VisiblePosition vp = visiblePositionFromCharacterOffset(characterOffset); + VisiblePosition startLine = startOfLine(vp); + + return characterOffsetFromVisiblePosition(startLine); +} + +CharacterOffset AXObjectCache::characterOffsetForIndex(int index, const AccessibilityObject* obj) +{ + if (!obj) + return CharacterOffset(); + + RefPtr<Range> range = obj->elementRange(); + CharacterOffset start = startOrEndCharacterOffsetForRange(range, true); + CharacterOffset end = startOrEndCharacterOffsetForRange(range, false); + CharacterOffset result = start; + for (int i = 0; i < index; i++) { + result = nextCharacterOffset(result, false); + if (result.isEqual(end)) + break; + } + return result; +} + +int AXObjectCache::indexForCharacterOffset(const CharacterOffset& characterOffset, AccessibilityObject* obj) +{ + // Create a collapsed range so that we can get the VisiblePosition from it. + RefPtr<Range> range = rangeForUnorderedCharacterOffsets(characterOffset, characterOffset); + if (!range) + return 0; + VisiblePosition vp = range->startPosition(); + return obj->indexForVisiblePosition(vp); +} + const Element* AXObjectCache::rootAXEditableElement(const Node* node) { const Element* result = node->rootEditableElement(); - const Element* element = node->isElementNode() ? toElement(node) : node->parentElement(); + const Element* element = is<Element>(*node) ? downcast<Element>(node) : node->parentElement(); for (; element; element = element->parentElement()) { if (nodeIsTextControl(element)) @@ -999,20 +2652,18 @@ const Element* AXObjectCache::rootAXEditableElement(const Node* node) void AXObjectCache::clearTextMarkerNodesInUse(Document* document) { - HashSet<Node*>::iterator it = m_textMarkerNodes.begin(); - HashSet<Node*>::iterator end = m_textMarkerNodes.end(); - - // Check each node to see if it's inside the document being deleted. + if (!document) + return; + + // Check each node to see if it's inside the document being deleted, of if it no longer belongs to a document. HashSet<Node*> nodesToDelete; - for (; it != end; ++it) { - if (&(*it)->document() == document) - nodesToDelete.add(*it); + for (const auto& node : m_textMarkerNodes) { + if (!node->isConnected() || &(node)->document() == document) + nodesToDelete.add(node); } - it = nodesToDelete.begin(); - end = nodesToDelete.end(); - for (; it != end; ++it) - m_textMarkerNodes.remove(*it); + for (const auto& node : nodesToDelete) + m_textMarkerNodes.remove(node); } bool AXObjectCache::nodeIsTextControl(const Node* node) @@ -1024,24 +2675,59 @@ bool AXObjectCache::nodeIsTextControl(const Node* node) return axObject && axObject->isTextControl(); } +void AXObjectCache::performDeferredIsIgnoredChange() +{ + for (auto* renderer : m_deferredIsIgnoredChangeList) + recomputeIsIgnored(renderer); + m_deferredIsIgnoredChangeList.clear(); +} + +void AXObjectCache::recomputeDeferredIsIgnored(RenderBlock& renderer) +{ + if (renderer.beingDestroyed()) + return; + m_deferredIsIgnoredChangeList.add(&renderer); +} + bool isNodeAriaVisible(Node* node) { if (!node) return false; - - // To determine if a node is ARIA visible, we need to check the parent hierarchy to see if anyone specifies - // aria-hidden explicitly. + + // ARIA Node visibility is controlled by aria-hidden + // 1) if aria-hidden=true, the whole subtree is hidden + // 2) if aria-hidden=false, and the object is rendered, there's no effect + // 3) if aria-hidden=false, and the object is NOT rendered, then it must have + // aria-hidden=false on each parent until it gets to a rendered object + // 3b) a text node inherits a parents aria-hidden value + bool requiresAriaHiddenFalse = !node->renderer(); + bool ariaHiddenFalsePresent = false; for (Node* testNode = node; testNode; testNode = testNode->parentNode()) { - if (testNode->isElementNode()) { - const AtomicString& ariaHiddenValue = toElement(testNode)->fastGetAttribute(aria_hiddenAttr); - if (equalIgnoringCase(ariaHiddenValue, "false")) - return true; - if (equalIgnoringCase(ariaHiddenValue, "true")) + if (is<Element>(*testNode)) { + const AtomicString& ariaHiddenValue = downcast<Element>(*testNode).attributeWithoutSynchronization(aria_hiddenAttr); + if (equalLettersIgnoringASCIICase(ariaHiddenValue, "true")) + return false; + + bool ariaHiddenFalse = equalLettersIgnoringASCIICase(ariaHiddenValue, "false"); + if (!testNode->renderer() && !ariaHiddenFalse) return false; + if (!ariaHiddenFalsePresent && ariaHiddenFalse) + ariaHiddenFalsePresent = true; + // We should break early when it gets to a rendered object. + if (testNode->renderer()) + break; } } - return false; + return !requiresAriaHiddenFalse || ariaHiddenFalsePresent; +} + +AccessibilityObject* AXObjectCache::rootWebArea() +{ + AccessibilityObject* rootObject = this->rootObject(); + if (!rootObject || !rootObject->isAccessibilityScrollView()) + return nullptr; + return downcast<AccessibilityScrollView>(*rootObject).webAreaObject(); } AXAttributeCacheEnabler::AXAttributeCacheEnabler(AXObjectCache* cache) @@ -1056,6 +2742,28 @@ AXAttributeCacheEnabler::~AXAttributeCacheEnabler() if (m_cache) m_cache->stopCachingComputedObjectAttributes(); } + +#if !PLATFORM(COCOA) +AXTextChange AXObjectCache::textChangeForEditType(AXTextEditType type) +{ + switch (type) { + case AXTextEditTypeCut: + case AXTextEditTypeDelete: + return AXTextDeleted; + case AXTextEditTypeInsert: + case AXTextEditTypeDictation: + case AXTextEditTypeTyping: + case AXTextEditTypePaste: + return AXTextInserted; + case AXTextEditTypeAttributesChange: + return AXTextAttributesChanged; + case AXTextEditTypeUnknown: + break; + } + ASSERT_NOT_REACHED(); + return AXTextInserted; +} +#endif } // namespace WebCore |