| /* |
| * Copyright (C) 2008-2025 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. |
| * 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. |
| * |
| * THIS SOFTWARE IS PROVIDED BY APPLE 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 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 "AXObjectCache.h" |
| |
| #include "AccessibilityNodeObjectInlines.h" |
| #include "AXAttributeCacheScope.h" |
| #include "AXComputedObjectAttributeCache.h" |
| #include "AXIsolatedObject.h" |
| #include "AXIsolatedTree.h" |
| #include "AXListHelpers.h" |
| #include "AXLocalFrame.h" |
| #include "AXLogger.h" |
| #include "AXLoggerBase.h" |
| #include "AXNotifications.h" |
| #include "AXObjectCacheInlines.h" |
| #include "AXRemoteFrame.h" |
| #include "AXTextMarker.h" |
| #include "AXTreeStoreInlines.h" |
| #include "AXUtilities.h" |
| #include "AccessibilityListBoxOption.h" |
| #include "AccessibilityMathMLElement.h" |
| #include "AccessibilityMenuList.h" |
| #include "AccessibilityMenuListOption.h" |
| #include "AccessibilityMenuListPopup.h" |
| #include "AccessibilityObjectInlines.h" |
| #include "AccessibilityProgressIndicator.h" |
| #include "AccessibilityRenderObject.h" |
| #include "AccessibilitySVGObject.h" |
| #include "AccessibilityScrollView.h" |
| #include "AccessibilityScrollbar.h" |
| #include "AccessibilitySlider.h" |
| #include "AccessibilitySpinButton.h" |
| #include "AccessibilityTableColumn.h" |
| #include "AccessibilityTableHeaderContainer.h" |
| #include "AriaNotifyOptions.h" |
| #include "BorderShape.h" |
| #include "CaretRectComputation.h" |
| #include "Chrome.h" |
| #include "ChromeClient.h" |
| #include "ContainerNodeInlines.h" |
| #include "CustomElementDefaultARIA.h" |
| #include "DeprecatedGlobalSettings.h" |
| #include "DocumentPage.h" |
| #include "DocumentView.h" |
| #include "EditingInlines.h" |
| #include "Editor.h" |
| #include "ElementAncestorIteratorInlines.h" |
| #include "ElementChildIteratorInlines.h" |
| #include "ElementRareData.h" |
| #include "EventNames.h" |
| #include "FocusController.h" |
| #include "FrameLoader.h" |
| #include "HTMLAnchorElement.h" |
| #include "HTMLAreaElement.h" |
| #include "HTMLButtonElement.h" |
| #include "HTMLCanvasElement.h" |
| #include "HTMLDetailsElement.h" |
| #include "HTMLDialogElement.h" |
| #include "HTMLImageElement.h" |
| #include "HTMLInputElement.h" |
| #include "HTMLLabelElement.h" |
| #include "HTMLMapElement.h" |
| #include "HTMLMediaElement.h" |
| #include "HTMLMeterElement.h" |
| #include "HTMLNames.h" |
| #include "HTMLOptGroupElement.h" |
| #include "HTMLOptionElement.h" |
| #include "HTMLProgressElement.h" |
| #include "HTMLSelectElement.h" |
| #include "HTMLSummaryElement.h" |
| #include "HTMLTableElement.h" |
| #include "HTMLTablePartElement.h" |
| #include "HTMLTableRowElement.h" |
| #include "HTMLTableSectionElement.h" |
| #include "HTMLTextFormControlElement.h" |
| #include "HitTestSource.h" |
| #include "InlineIteratorLogicalOrderTraversal.h" |
| #include "InlineRunAndOffset.h" |
| #include "LocalFrame.h" |
| #include "Logging.h" |
| #include "MathMLElement.h" |
| #include "Page.h" |
| #include "ProgressTracker.h" |
| #include "Range.h" |
| #include "RemoteFrame.h" |
| #include "RemoteFrameView.h" |
| #include "RenderAttachment.h" |
| #include "RenderBox.h" |
| #include "RenderElementStyleInlines.h" |
| #include "RenderImage.h" |
| #include "RenderInline.h" |
| #include "RenderLayer.h" |
| #include "RenderLineBreak.h" |
| #include "RenderListBox.h" |
| #include "RenderListOutsideMarker.h" |
| #include "RenderMathMLOperator.h" |
| #include "RenderMeter.h" |
| #include "RenderObjectInlines.h" |
| #include "RenderProgress.h" |
| #include "RenderSVGInlineText.h" |
| #include "RenderSlider.h" |
| #include "RenderTable.h" |
| #include "RenderTableCell.h" |
| #include "RenderTableRow.h" |
| #include "RenderView.h" |
| #include "SVGAElement.h" |
| #include "SVGElement.h" |
| #include "SVGElementTypeHelpers.h" |
| #include "ScriptDisallowedScope.h" |
| #include "ScrollView.h" |
| #include "SelectPopoverElement.h" |
| #include "Settings.h" |
| #include "ShadowRoot.h" |
| #include "TextBoundaries.h" |
| #include "TextControlInnerElements.h" |
| #include "TextIterator.h" |
| #include "TypedElementDescendantIteratorInlines.h" |
| #include <utility> |
| #include <wtf/Borrow.h> |
| #include <wtf/DataLog.h> |
| #include <wtf/HexNumber.h> |
| #include <wtf/NeverDestroyed.h> |
| #include <wtf/SetForScope.h> |
| #include <wtf/TZoneMallocInlines.h> |
| #include <wtf/text/AtomString.h> |
| #include <wtf/text/MakeString.h> |
| |
| #if PLATFORM(COCOA) |
| #include "AXLiveRegionManager.h" |
| #include <wtf/spi/darwin/OSVariantSPI.h> |
| #endif |
| |
| namespace WebCore { |
| |
| DEFINE_ALLOCATOR_WITH_HEAP_IDENTIFIER(AXObjectCache); |
| WTF_MAKE_TZONE_ALLOCATED_IMPL(AXObjectCache); |
| |
| using namespace HTMLNames; |
| |
| #if PLATFORM(COCOA) |
| |
| // Post notifications for secure fields or elements contained in secure fields at a 40hz interval to thwart analysis of typing cadence. |
| static const Seconds accessibilityPasswordValueChangeNotificationInterval { 25_ms }; |
| |
| static bool isSecureFieldOrContainedBySecureField(AccessibilityObject& object) |
| { |
| return object.isSecureField() || object.isContainedBySecureField(); |
| } |
| |
| #endif // PLATFORM(COCOA) |
| |
| static bool NODELETE rendererNeedsDeferredUpdate(const RenderObject& renderer) |
| { |
| AX_ASSERT(!renderer.beingDestroyed()); |
| auto& document = renderer.document(); |
| return renderer.needsLayout() || document.needsStyleRecalc() || document.inRenderTreeUpdate() || (document.view() && document.view()->layoutContext().isInRenderTreeLayout()); |
| } |
| |
| static bool NODELETE nodeRendererIsValid(Node& node) |
| { |
| auto* renderer = node.renderer(); |
| return renderer && !renderer->beingDestroyed(); |
| } |
| |
| static bool NODELETE nodeAndRendererAreValid(Node* node) |
| { |
| return node ? nodeRendererIsValid(*node) : false; |
| } |
| |
| AccessibilityReplacedText::AccessibilityReplacedText(const VisibleSelection& selection) |
| { |
| if (AXObjectCache::accessibilityEnabled()) { |
| m_replacedRange.startIndex.value = indexForVisiblePosition(selection.visibleStart(), m_replacedRange.startIndex.scope); |
| if (selection.isRange()) { |
| m_replacedText = AccessibilityObject::stringForVisiblePositionRange(selection); |
| m_replacedRange.endIndex.value = indexForVisiblePosition(selection.visibleEnd(), 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.get(), AXTextEditType::Delete, m_replacedText, type, text, position); |
| else |
| cache->postTextStateChangeNotification(node.get(), type, text, position); |
| } |
| |
| std::atomic<AccessibilityMode> AXObjectCache::gAccessibilityMode { AccessibilityMode::Off }; |
| bool AXObjectCache::gAccessibilityEnhancedUserInterfaceEnabled = false; |
| std::atomic<bool> AXObjectCache::gForceDeferredSpellChecking = false; |
| std::atomic<bool> AXObjectCache::gAccessibilityTextStitchingEnabled = false; |
| std::atomic<bool> AXObjectCache::gAccessibilityThreadHitTestingEnabled = false; |
| std::atomic<bool> AXObjectCache::gForceInitialFrameCaching = false; |
| #if PLATFORM(COCOA) |
| std::atomic<bool> AXObjectCache::gAccessibilityDOMIdentifiersEnabled = false; |
| std::atomic<bool> AXObjectCache::gShouldRepostNotificationsForTests = false; |
| #endif |
| |
| static AXObjectCache::SyncModeToOtherProcessesCallback& syncModeToOtherProcessesCallback() |
| { |
| static NeverDestroyed<AXObjectCache::SyncModeToOtherProcessesCallback> callback; |
| return callback.get(); |
| } |
| |
| void AXObjectCache::setSyncModeToOtherProcessesCallback(SyncModeToOtherProcessesCallback&& callback) |
| { |
| syncModeToOtherProcessesCallback() = WTF::move(callback); |
| } |
| |
| std::optional<AccessibilityMode> resolveAccessibilityModeTransition(AccessibilityMode current, AccessibilityMode requested) |
| { |
| #if !ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (requested == AccessibilityMode::AXThread) |
| requested = AccessibilityMode::MainThread; |
| #endif |
| |
| if (current == requested) |
| return std::nullopt; |
| |
| if (isAccessibilityModeOff(current)) { |
| if (requested == AccessibilityMode::MainThread || requested == AccessibilityMode::AXThread) |
| return requested; |
| return std::nullopt; |
| } |
| |
| bool isOffRequested = isAccessibilityModeOff(requested); |
| if (current == AccessibilityMode::MainThread) { |
| if (requested == AccessibilityMode::AXThread) |
| return requested; |
| if (isOffRequested) |
| return AccessibilityMode::OffWasMainThread; |
| return std::nullopt; |
| } |
| |
| if (current == AccessibilityMode::AXThread && isOffRequested) |
| return AccessibilityMode::OffWasAXThread; |
| |
| // Default to disallowing the transition. |
| return std::nullopt; |
| } |
| |
| enum class ShouldSyncToOtherProcesses : bool { No, Yes }; |
| static std::optional<AccessibilityMode> attemptModeTransition(AccessibilityMode requestedMode, ShouldSyncToOtherProcesses shouldSyncToOtherProcesses = ShouldSyncToOtherProcesses::Yes) |
| { |
| std::optional resolvedMode = resolveAccessibilityModeTransition(AXObjectCache::accessibilityMode(), requestedMode); |
| if (!resolvedMode) |
| return std::nullopt; |
| |
| AXObjectCache::gAccessibilityMode.store(*resolvedMode, std::memory_order_relaxed); |
| |
| if (shouldSyncToOtherProcesses == ShouldSyncToOtherProcesses::Yes) { |
| if (auto& callback = syncModeToOtherProcessesCallback()) |
| callback(*resolvedMode); |
| } |
| return resolvedMode; |
| } |
| |
| std::optional<AccessibilityMode> AXObjectCache::attemptMainThreadModeTransition() |
| { |
| return attemptModeTransition(AccessibilityMode::MainThread); |
| } |
| |
| void AXObjectCache::disableAccessibilityForTesting() |
| { |
| if (isAccessibilityModeOff(accessibilityMode())) |
| return; |
| |
| // See comment for this function in the header to understand |
| // why we don't sync this mode change outside this process. |
| [[maybe_unused]] std::optional newMode = attemptModeTransition(AccessibilityMode::Off, ShouldSyncToOtherProcesses::No); |
| // Assuming proper context (i.e. this is a test client), the transition should |
| // always be successful. Anything else indicates a programming error. |
| AX_ASSERT(newMode); |
| AX_ASSERT(isAccessibilityModeOff(*newMode)); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| std::optional<AccessibilityMode> AXObjectCache::transitionToAXThreadModeIfNeeded(AXThreadModePreconditions preconditions) |
| { |
| if (accessibilityMode() == AccessibilityMode::AXThread) |
| return std::nullopt; |
| |
| if (platformAXThreadSupport(preconditions) == PlatformAXThreadSupport::NotSupported) |
| return std::nullopt; |
| |
| if (preconditions != AXThreadModePreconditions::None |
| && !DeprecatedGlobalSettings::isAccessibilityIsolatedTreeEnabled()) |
| return std::nullopt; |
| |
| // At this point we *must* be on the main-thread (our mode was not AXThread and |
| // thus the accessibility thread should not have been started). This is important |
| // because we call several functions that must be run on the main-thread, like |
| // attemptModeTransition which can initiate IPC (and sending IPC is main-thread only) |
| // and iterating over the AXObjectCaches in this process. |
| AX_ASSERT(isMainThread()); |
| |
| // Set mode before building all the isolated trees so any code |
| // triggered during tree building that checks the mode sees AXThread. |
| // Don't sync yet — we need to confirm the secondary thread starts |
| // successfully before notifying other processes. |
| auto previousMode = accessibilityMode(); |
| std::optional newMode = attemptModeTransition(AccessibilityMode::AXThread, ShouldSyncToOtherProcesses::No); |
| if (!newMode || *newMode != AccessibilityMode::AXThread) { |
| // The mode change wasn't successful — do not start the secondary thread |
| // or build any isolated trees. |
| // |
| // We should either fail the transition outright (std::nullopt), or successfully |
| // transition to AXThread mode. Nothing else is expected. |
| AX_ASSERT(!newMode); |
| return newMode; |
| } |
| |
| // Initialize the role map before the accessibility thread starts so that |
| // it's safe for both threads to use (the only thing that needs to be |
| // thread-safe about it is initialization since it's not modified after |
| // creation and is never destroyed). |
| Accessibility::initializeRoleMap(); |
| |
| if (platformStartSecondaryThread() == DidStartThread::No && !clientIsInTestMode()) { |
| // Failed to start the secondary thread. Revert to the previous mode. |
| // In test contexts, failing to start the real AX thread is expected |
| // because the test runner uses its own fake AX thread. |
| gAccessibilityMode.store(previousMode, std::memory_order_relaxed); |
| return std::nullopt; |
| } |
| |
| // The transition succeeded — now sync the mode to other processes. |
| if (auto& callback = syncModeToOtherProcessesCallback()) |
| callback(*newMode); |
| |
| // Build isolated trees for all existing AXObjectCaches. |
| forEachAXObjectCache([](AXObjectCache& cache) { |
| cache.getOrCreateIsolatedTree(); |
| }); |
| |
| return newMode; |
| } |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| |
| bool AXObjectCache::accessibilityEnhancedUserInterfaceEnabled() |
| { |
| AX_ASSERT(isMainThread()); |
| return gAccessibilityEnhancedUserInterfaceEnabled; |
| } |
| |
| void AXObjectCache::setEnhancedUserInterfaceAccessibility(bool flag) |
| { |
| AX_ASSERT(isMainThread()); |
| gAccessibilityEnhancedUserInterfaceEnabled = flag; |
| #if PLATFORM(MAC) |
| if (flag) |
| enableAccessibility(); |
| #endif |
| } |
| |
| bool AXObjectCache::isAXThreadHitTestingEnabled() |
| { |
| return gAccessibilityThreadHitTestingEnabled; |
| } |
| |
| void AXObjectCache::setForceInitialFrameCaching(bool shouldForce) |
| { |
| gForceInitialFrameCaching = shouldForce; |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| bool AXObjectCache::shouldServeInitialCachedFrame() |
| { |
| return !clientIsInTestMode() || forceInitialFrameCaching(); |
| } |
| |
| // Just over one frame at 60Hz (16.67ms). The timer is scheduled on demand when the isolated |
| // tree first queues work, so this is "publish one frame after the first change in this batch", |
| // with any further changes in the window coalescing into the same commit. |
| static constexpr Seconds updateTreeSnapshotTimerDuration { 17_ms }; |
| #endif |
| |
| AXObjectCache::AXObjectCache(LocalFrame& localFrame, Document* document) |
| : m_document(document) |
| , m_frameID(localFrame.frameID()) |
| , m_notificationPostTimer(*this, &AXObjectCache::notificationPostTimerFired) |
| , m_pendingAnnouncementTimeoutTimer(*this, &AXObjectCache::pendingAnnouncementTimeoutTimerFired) |
| #if PLATFORM(COCOA) |
| , m_passwordNotificationTimer(*this, &AXObjectCache::passwordNotificationTimerFired) |
| #endif |
| , m_liveRegionChangedPostTimer(*this, &AXObjectCache::liveRegionChangedNotificationPostTimerFired) |
| , m_currentModalElement(nullptr) |
| , m_performCacheUpdateTimer(*this, &AXObjectCache::performCacheUpdateTimerFired) |
| , m_geometryManager(AXGeometryManager::create(*this)) |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| , m_buildIsolatedTreeTimer(*this, &AXObjectCache::buildIsolatedTree) |
| , m_selectedTextRangeTimer(*this, &AXObjectCache::selectedTextRangeTimerFired, platformSelectedTextRangeDebounceInterval()) |
| , m_updateTreeSnapshotTimer(*this, &AXObjectCache::updateTreeSnapshotTimerFired) |
| #endif |
| { |
| AXTRACE(makeString("AXObjectCache::AXObjectCache 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| #ifndef NDEBUG |
| AXLOG(makeString("frameID "_s, m_frameID.loggingString())); |
| #endif |
| AX_ASSERT(isMainThread()); |
| |
| #if !LOG_DISABLED || !RELEASE_LOG_DISABLED |
| setAccessibilityLogChannelEnabled(LOG_CHANNEL(Accessibility).state != logChannelStateOff); |
| #endif |
| |
| gAccessibilityTextStitchingEnabled = DeprecatedGlobalSettings::accessibilityTextStitchingEnabled(); |
| gAccessibilityThreadHitTestingEnabled = DeprecatedGlobalSettings::accessibilityThreadHitTestingEnabled(); |
| |
| #if PLATFORM(COCOA) |
| initializeUserDefaultValues(); |
| #endif |
| |
| // If loading completed before the cache was created, loading progress will have been reset to zero. |
| // Consider loading progress to be 100% in this case. |
| if (auto* page = localFrame.page()) { |
| m_loadingProgress = page->progress().estimatedProgress(); |
| m_pageActivityState = page->activityState(); |
| } |
| |
| if (m_loadingProgress <= 0) |
| m_loadingProgress = 1; |
| |
| #if PLATFORM(COCOA) |
| if (RefPtr document = m_document.get()) { |
| if (document->settings().isAriaLiveRegionManagementEnabled()) |
| m_liveRegionManager = makeUnique<AXLiveRegionManager>(*this); |
| } |
| #endif |
| |
| AXTreeStore::add(m_id, WeakPtr { this }); |
| |
| #if ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| // This is the first time this frame has its cache initialized, so it has no geometry. Kick off a request to initialize it asynchronously. |
| if (RefPtr page = localFrame.page()) |
| page->chrome().client().requestFrameScreenPosition(m_frameID); |
| #endif |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (isIsolatedTreeEnabled() && !clientIsInTestMode()) { |
| // Proactively (and asynchronously) queue up the build of the isolated tree associated with this cache, |
| // guaranteeing it gets built rather than implicitly relying on something later calling getOrCreateIsolatedTree() |
| // on |this| instance. Doing this here is critical — otherwise, after navigation, nothing may actually |
| // call getOrCreateIsolatedTree() on |this|, leaving web content empty forever. |
| // |
| // Do not do this in test mode, for which we build the full tree synchronously in getOrCreateIsolatedTree() |
| // (unlike the real-AT path, where we serve a placeholder while the full tree gets built via this timer). |
| m_buildIsolatedTreeTimer.startOneShot(0_s); |
| } |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| } |
| |
| AXObjectCache::~AXObjectCache() |
| { |
| AXTRACE(makeString("AXObjectCache::~AXObjectCache 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| |
| m_notificationPostTimer.stop(); |
| m_liveRegionChangedPostTimer.stop(); |
| m_performCacheUpdateTimer.stop(); |
| m_pendingAnnouncementTimeoutTimer.stop(); |
| |
| for (const auto& object : m_objects.values()) |
| object->detach(AccessibilityDetachmentType::CacheDestroyed); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| m_buildIsolatedTreeTimer.stop(); |
| m_selectedTextRangeTimer.stop(); |
| m_updateTreeSnapshotTimer.stop(); |
| |
| RefPtr existingTree = AXIsolatedTree::treeForFrameID(m_frameID); |
| if (existingTree) { |
| existingTree->setPageActivityState({ }); |
| if (existingTree->treeID() == treeID()) { |
| // Only remove the tree if it belongs to this cache. During cross-document navigation, a new |
| // AXObjectCache may have already been created for the same frameID and stored a fresh tree in |
| // treeFrameCache. We must not wipe the new cache's tree when the previous cache is finally |
| // destructed. Without this guard, the new cache's AXLocalFrame parent loses its tree mapping |
| // and AXIsolatedObject::crossFrameChildObject returns null, breaking iframe access in ITM. |
| AXIsolatedTree::removeTreeForFrameID(m_frameID); |
| } |
| } |
| #endif |
| |
| AXTreeStore::remove(m_id); |
| } |
| |
| String AXObjectCache::debugDescription() const |
| { |
| TextStream stream; |
| stream << this; |
| RefPtr document = m_document.get(); |
| return makeString( |
| "AXObjectCache "_s, |
| stream.release(), |
| " { "_s, |
| document ? document->debugDescription() : "null document"_s, |
| " }"_s |
| ); |
| } |
| |
| String AXNotificationWithData::debugDescription() const |
| { |
| TextStream stream; |
| stream << "AXNotificationWithData { notification: " << notification; |
| WTF::switchOn(data, |
| [&] (const std::monostate&) { }, |
| [&] (const AriaNotifyData& ariaData) { |
| stream << ", data: " << ariaData.debugDescription(); |
| } |
| #if PLATFORM(COCOA) |
| , [&] (const LiveRegionAnnouncementData& liveRegionData) { |
| stream << ", data: " << liveRegionData.debugDescription(); |
| } |
| #endif |
| ); |
| stream << " }"; |
| return stream.release(); |
| } |
| |
| bool AXObjectCache::isModalElement(Element& element) const |
| { |
| if (hasAnyRole(element, { "dialog"_s, "alertdialog"_s }) && equalLettersIgnoringASCIICase(element.attributeWithDefaultARIA(aria_modalAttr), "true"_s)) |
| return true; |
| |
| RefPtr dialog = dynamicDowncast<HTMLDialogElement>(element); |
| return dialog && dialog->isModal(); |
| } |
| |
| void AXObjectCache::findModalNodes() |
| { |
| // Traverse the DOM tree to look for the aria-modal=true nodes or modal <dialog> elements. |
| RefPtr document = this->document(); |
| for (RefPtr element = document ? ElementTraversal::firstWithin(document->rootNode()) : nullptr; element; element = ElementTraversal::nextIncludingPseudo(*element)) { |
| if (isModalElement(*element)) |
| m_modalElements.append(element.get()); |
| } |
| |
| // Arm the aria-hidden override check in case a modal is blocked by |
| // ancestor aria-hidden with an otherwise empty page. |
| if (!m_modalElements.isEmpty()) |
| m_needsAriaHiddenModalOverrideCheck = true; |
| |
| m_modalNodesInitialized = true; |
| } |
| |
| static bool isNodeAccessible(const Node* node) |
| { |
| RefPtr element = dynamicDowncast<Element>(node); |
| if (!element) |
| return false; |
| |
| CheckedPtr renderer = element->renderer(); |
| if (!renderer) |
| return false; |
| |
| CheckedRef style = renderer->style(); |
| if (style->display() == Style::DisplayType::None) |
| return false; |
| |
| if (style->effectiveInert()) |
| return false; |
| |
| CheckedPtr renderLayer = renderer->enclosingLayer(); |
| if (isVisibilityHidden(style) && renderLayer && !renderLayer->hasVisibleContent()) |
| return false; |
| |
| // Check whether this object or any of its ancestors has opacity 0. |
| // The resulting opacity of a RenderObject is computed as the multiplication |
| // of its opacity times the opacities of its ancestors. |
| for (auto* ancestor = renderer.get(); ancestor; ancestor = ancestor->parent()) { |
| if (ancestor->style().opacity().isTransparent()) |
| return false; |
| } |
| |
| // We also need to consider aria hidden status. |
| return !equalLettersIgnoringASCIICase(element->attributeWithDefaultARIA(aria_hiddenAttr), "true"_s) || element->focused(); |
| } |
| |
| bool AXObjectCache::modalElementHasAccessibleContent(Element& element) |
| { |
| // Unless you're trying to compute the new modal node, determining whether an element |
| // has accessible content is as easy as !getOrCreate(element)->unignoredChildren().isEmpty(). |
| // So don't call this method on anything besides modal elements. |
| AX_ASSERT(isModalElement(element)); |
| |
| // Because computing any object's unignored children is dependent on whether a modal is on the page, |
| // we'll need to walk the DOM and find non-ignored AX objects manually. |
| Vector<Node*> nodeStack = { element.firstChild() }; |
| while (!nodeStack.isEmpty()) { |
| for (RefPtr node = nodeStack.takeLast(); node; node = node->nextSibling()) { |
| if (RefPtr axObject = getOrCreate(*node)) { |
| if (!axObject->computeIsIgnored()) |
| return true; |
| |
| #if USE(ATSPI) |
| // When using ATSPI, an accessibility object with 'StaticText' role is ignored. |
| // Its content is exposed by its parent. |
| // Treat such elements as having accessible content. |
| // FIXME: This may not be sufficient for visibility:hidden or inert (https://bugs.webkit.org/show_bug.cgi?id=280914). |
| if (axObject->role() == AccessibilityRole::StaticText && !axObject->isAXHidden()) |
| return true; |
| #endif |
| } |
| |
| // Don't descend into subtrees for non-accessible nodes — isNodeAccessible |
| // also skips aria-hidden and inert subtrees, so nothing inside can be accessible content. |
| if (isNodeAccessible(node.get())) |
| nodeStack.append(node->firstChild()); |
| } |
| } |
| return false; |
| } |
| |
| void AXObjectCache::updateCurrentModalNode() |
| { |
| auto recomputeModalElement = [&] () -> Element* { |
| // There might be multiple modal dialog nodes. |
| // We use this function to pick the one we want. |
| if (m_modalElements.isEmpty()) |
| return nullptr; |
| |
| RefPtr document = this->document(); |
| if (!document) |
| return nullptr; |
| |
| // Pick the document active modal <dialog> element if it exists. |
| if (RefPtr activeModalDialog = document->activeModalDialog()) { |
| AX_ASSERT(m_modalElements.contains(activeModalDialog.get())); |
| return activeModalDialog.unsafeGet(); |
| } |
| |
| SetForScope retrievingCurrentModalNode(m_isRetrievingCurrentModalNode, true); |
| // We only ever return a modal that contains the keyboard focus, so |
| // walk up from the focused element to find the deepest focus-containing |
| // modal that's also visible and has accessible content. |
| RefPtr<Element> focusedElement = document->focusedElement(); |
| if (!focusedElement) |
| return nullptr; |
| RefPtr<Element> modalElementToReturn; |
| for (RefPtr ancestor = focusedElement; ancestor; ancestor = ancestor->parentOrShadowHostElement()) { |
| |
| if (!m_modalElements.containsIf([&ancestor] (auto& modal) { |
| return modal.get() == ancestor.get(); |
| })) |
| continue; |
| |
| // Elements in m_modalElementsSet may have become un-modal since we added them, |
| // but not yet removed as part of the asynchronous m_deferredModalChangedList |
| // handling. Skip these. |
| if (!isModalElement(*ancestor)) |
| continue; |
| |
| // To avoid trapping users in an empty modal, skip any non-visible |
| // element, or any element without accessible content. Continue |
| // walking the ancestry in case a containing modal is visible and contentful. |
| if (!isNodeAccessible(ancestor.get()) || !modalElementHasAccessibleContent(*ancestor)) |
| continue; |
| modalElementToReturn = ancestor; |
| break; |
| } |
| |
| if (!modalElementToReturn) |
| return nullptr; |
| |
| RefPtr object = getOrCreate(modalElementToReturn.get()); |
| if (!object || object->isAXHidden()) |
| return nullptr; |
| return modalElementToReturn.unsafeGet(); |
| }; |
| |
| RefPtr previousModal = m_currentModalElement.get(); |
| m_currentModalElement = recomputeModalElement(); |
| if (previousModal.get() != m_currentModalElement.get()) { |
| childrenChanged(protect(rootWebArea())); |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| // Because the presence of a modal affects every element on the page, |
| // regenerate the entire isolated tree with the next cache update. |
| m_deferredRegenerateIsolatedTree = true; |
| #endif |
| } |
| } |
| |
| // This function returns the valid aria modal node. |
| Node* AXObjectCache::modalNode() |
| { |
| if (!m_modalNodesInitialized) |
| findModalNodes(); |
| |
| if (m_modalElements.isEmpty()) |
| return nullptr; |
| |
| // Check the cached current valid aria modal node first. |
| // Usually when one dialog sets aria-modal=true, that dialog is the one we want. |
| if (isNodeAccessible(protect(m_currentModalElement.get()))) |
| return m_currentModalElement; |
| |
| // Recompute the valid aria modal node when m_currentModalElement is null or hidden. |
| updateCurrentModalNode(); |
| |
| if (m_needsAriaHiddenModalOverrideCheck && !m_currentModalElement) { |
| m_needsAriaHiddenModalOverrideCheck = false; |
| |
| // This block is designed to fix a web developer mistake where an aria-modal |
| // is added to the page, all content outside the modal is inert, and the author |
| // mistakenly leaves the modal as aria-hidden. This is an extremely specific |
| // scenario, but one we found on a real, very popular webpage, and it's an |
| // understandable mistake. Only do this if literally nothing on the page is |
| // accessible (!m_unignoredContentObjectCount). |
| if (!m_unignoredContentObjectCount) { |
| for (auto& modal : m_modalElements) { |
| if (!modal || !isModalElement(*modal)) |
| continue; |
| |
| RefPtr<Element> ariaHiddenAncestor; |
| bool isVisible = modal->renderer() && modal->renderer()->style().display() != Style::DisplayType::None; |
| for (RefPtr current = modal.get(); current && isVisible; current = current->parentElementInComposedTree()) { |
| if (auto* renderer = current->renderer()) { |
| if (renderer->style().opacity().isTransparent()) { |
| isVisible = false; |
| break; |
| } |
| } |
| |
| if (!ariaHiddenAncestor && equalLettersIgnoringASCIICase(current->attributeWithDefaultARIA(aria_hiddenAttr), "true"_s)) |
| ariaHiddenAncestor = current; |
| } |
| |
| if (!isVisible || !ariaHiddenAncestor) |
| continue; |
| |
| if (RefPtr axObject = getOrCreate(*ariaHiddenAncestor)) { |
| axObject->setShouldIgnoreARIAHidden(true); |
| handleAriaHiddenChange(*ariaHiddenAncestor); |
| } |
| break; |
| } |
| } |
| } |
| |
| return m_currentModalElement; |
| } |
| |
| 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. |
| RefPtr imageElement = areaElement.imageElement(); |
| if (!imageElement) |
| return nullptr; |
| |
| RefPtr axRenderImage = protect(areaElement.document())->axObjectCache()->getOrCreate(*imageElement); |
| if (!axRenderImage) |
| return nullptr; |
| |
| for (const auto& child : axRenderImage->unignoredChildren()) { |
| if (child->isImageMapLink() && child->node() == &areaElement) |
| return dynamicDowncast<AccessibilityObject>(child.get()); |
| } |
| return nullptr; |
| } |
| |
| AccessibilityObject* AXObjectCache::focusedObjectForPage(const Page* page) |
| { |
| #if ENABLE_ACCESSIBILITY_LOCAL_FRAME |
| return focusedObjectForLocalFrame(); |
| #endif |
| |
| AX_ASSERT(isMainThread()); |
| |
| if (!accessibilityEnabled()) |
| return nullptr; |
| |
| // get the focused node in the page |
| RefPtr focusedOrMainFrame = page->focusController().focusedOrMainFrame(); |
| if (!focusedOrMainFrame) |
| return nullptr; |
| RefPtr document = focusedOrMainFrame->document(); |
| if (!document) |
| return nullptr; |
| |
| document->updateStyleIfNeeded(); |
| |
| if (RefPtr remoteFrame = dynamicDowncast<RemoteFrame>(page->focusController().focusedFrame())) { |
| // Check if focus is in a site-isolated sub-frame. If so, return the AXRemoteFrame |
| // so ATs can follow it to the remote process to get the actual focused element. |
| if (RefPtr remoteFrameView = remoteFrame->view()) { |
| if (RefPtr scrollView = dynamicDowncast<AccessibilityScrollView>(getOrCreate(remoteFrameView.get()))) |
| return scrollView->remoteFrame().unsafeGet(); |
| } |
| } |
| |
| if (RefPtr focusedElement = document->focusedElement()) |
| return focusedObjectForNode(focusedElement.get()); |
| return focusedObjectForNode(document.get()); |
| } |
| |
| AccessibilityObject* AXObjectCache::focusedObjectForLocalFrame() |
| { |
| AX_ASSERT(isMainThread()); |
| |
| if (!accessibilityEnabled()) |
| return nullptr; |
| |
| RefPtr document = this->document(); |
| if (!document) |
| return nullptr; |
| |
| RefPtr page = document->page(); |
| if (!page) |
| return nullptr; |
| |
| #if ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| // If focus is in a different local (in-process) frame, return the AXLocalFrame proxying the direct |
| // child frame leading toward it (or nullptr if it isn't a descendant of this cache's frame), so this |
| // tree's focus chains into the focused subframe and assistive technologies can descend cross-frame to |
| // the real focused element (see AXIsolatedObject::focusedUIElementInAnyLocalFrame()). A null |
| // localFocusedFrame means focus is in a remote (site-isolated) frame or nowhere; fall through to the |
| // RemoteFrame branch below. |
| RefPtr localFocusedFrame = page->focusController().localFocusedFrame(); |
| if (localFocusedFrame && localFocusedFrame->document() != document.get()) |
| return localFrameLeadingToFocusedFrame(); |
| #endif // ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| |
| if (RefPtr remoteFrame = dynamicDowncast<RemoteFrame>(page->focusController().focusedFrame())) { |
| // Check if focus is in a site-isolated sub-frame. If so, return the AXRemoteFrame |
| // so ATs can follow it to the remote process to get the actual focused element. |
| if (RefPtr remoteFrameView = remoteFrame->view()) { |
| if (RefPtr scrollView = dynamicDowncast<AccessibilityScrollView>(getOrCreate(remoteFrameView.get()))) |
| return scrollView->remoteFrame().unsafeGet(); |
| } |
| } |
| |
| document->updateStyleIfNeeded(); |
| if (RefPtr focusedElement = document->focusedElement()) |
| return focusedObjectForNode(focusedElement.get()); |
| return focusedObjectForNode(document.get()); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| AccessibilityObject* AXObjectCache::localFrameLeadingToFocusedFrame() |
| { |
| AX_ASSERT(isMainThread()); |
| |
| RefPtr document = this->document(); |
| if (!document) |
| return nullptr; |
| |
| // focusedElementInScope() (the resolution behind Document::activeElement()) returns the frame owner |
| // element (the <iframe>) in this document on the path toward the focused subframe, walking the frame |
| // tree via focusedFrameOwnerElement(). Map that element to the AXLocalFrame proxying the child frame's |
| // content, so this cache's tree chains its focus into the focused subframe. Anything that is not a |
| // local frame owner (focus is in this document, or in a remote/non-descendant frame) yields nullptr. |
| RefPtr owner = dynamicDowncast<HTMLFrameOwnerElement>(document->focusedElementInScope()); |
| RefPtr childLocalFrame = dynamicDowncast<LocalFrame>(owner ? owner->contentFrame() : nullptr); |
| RefPtr childFrameView = childLocalFrame ? childLocalFrame->view() : nullptr; |
| if (!childFrameView) |
| return nullptr; |
| |
| // The AXLocalFrame lives on this (parent) cache's FrameHost scroll view for the child frame view. |
| RefPtr scrollView = dynamicDowncast<AccessibilityScrollView>(getOrCreate(childFrameView.get())); |
| return scrollView ? scrollView->localFrame() : nullptr; |
| } |
| #endif // ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) && ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| void AXObjectCache::updateAncestorFramesFocusedObject() |
| { |
| AX_ASSERT(isMainThread()); |
| |
| RefPtr document = this->document(); |
| RefPtr frame = document ? document->frame() : nullptr; |
| for (RefPtr<Frame> ancestor = frame ? frame->tree().parent() : nullptr; ancestor; ancestor = ancestor->tree().parent()) { |
| RefPtr localAncestorFrame = dynamicDowncast<LocalFrame>(ancestor.get()); |
| RefPtr ancestorDocument = localAncestorFrame ? localAncestorFrame->document() : nullptr; |
| // focusedObjectForLocalFrame() returns the AXLocalFrame leading toward the focused subframe |
| // for an ancestor cache, so this points each ancestor tree's focus at the correct child frame. |
| if (CheckedPtr ancestorCache = ancestorDocument ? ancestorDocument->existingAXObjectCache() : nullptr) { |
| RefPtr ancestorFocus = ancestorCache->focusedObjectForLocalFrame(); |
| ancestorCache->setIsolatedTreeFocusedObject(ancestorFocus.get()); |
| } |
| } |
| } |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) && ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| |
| AccessibilityObject* AXObjectCache::focusedObjectForNode(Node* focusedNode) |
| { |
| if (auto* area = dynamicDowncast<HTMLAreaElement>(focusedNode)) |
| return focusedImageMapUIElement(*area); |
| |
| RefPtr focus = getOrCreate(focusedNode); |
| if (!focus) |
| return nullptr; |
| |
| if (focus->shouldFocusActiveDescendant()) { |
| if (RefPtr descendant = focus->activeDescendant()) |
| return dynamicDowncast<AccessibilityObject>(descendant.unsafeGet()); |
| } |
| |
| if (focus->isIgnored()) |
| return focus->parentObjectUnignored(); |
| return focus.unsafeGet(); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| void AXObjectCache::setIsolatedTreeFocusedObject(AccessibilityObject* focus) |
| { |
| AX_ASSERT(isMainThread()); |
| |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->setFocusedNodeID(focus ? std::optional { focus->objectID() } : std::nullopt); |
| } |
| #endif |
| |
| IntPoint AXObjectCache::mapScreenPointToPagePoint(const IntPoint& screenRelativePoint) const |
| { |
| RefPtr page = this->page(); |
| if (!page) |
| return screenRelativePoint; |
| |
| RefPtr frame = m_document ? m_document->frame() : nullptr; |
| RefPtr frameView = frame ? frame->view() : nullptr; |
| |
| // Try to use cached accessibility position to avoid sync IPC (macOS only). |
| IntPoint convertedPoint; |
| if (auto localResult = page->chrome().client().screenToRootViewUsingCachedPosition(screenRelativePoint, frameView ? frameView->size() : IntSize())) |
| convertedPoint = *localResult; |
| else |
| convertedPoint = page->chrome().client().screenToRootView(screenRelativePoint); |
| |
| if (frameView) |
| convertedPoint.moveBy(frameView->scrollPosition()); |
| |
| auto obscuredContentInsets = page->obscuredContentInsets(); |
| convertedPoint.move(-obscuredContentInsets.left(), -obscuredContentInsets.top()); |
| return convertedPoint; |
| } |
| |
| RefPtr<Page> AXObjectCache::page() const |
| { |
| return m_document ? m_document->page() : nullptr; |
| } |
| |
| Ref<AccessibilityRenderObject> AXObjectCache::createObjectFromRenderer(RenderObject& renderer) |
| { |
| RefPtr node = renderer.node(); |
| if (RefPtr element = dynamicDowncast<Element>(node)) { |
| // Lists shouldn't fallthrough to table components, so explicitly create a render object. |
| if (AXListHelpers::isAccessibilityList(*element)) |
| return AccessibilityRenderObject::create(AXID::generate(), renderer, *this); |
| } |
| |
| if (renderer.isRenderOrLegacyRenderSVGRoot()) |
| return AccessibilitySVGObject::create(AXID::generate(), renderer, *this, /* isSVGRoot */ true); |
| |
| if (is<SVGElement>(node) || is<RenderSVGInlineText>(renderer)) |
| return AccessibilitySVGObject::create(AXID::generate(), renderer, *this); |
| |
| if (CheckedPtr renderImage = toSimpleImage(renderer)) |
| return AccessibilityRenderObject::create(AXID::generate(), *renderImage, *this); |
| |
| #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(AXID::generate(), renderer, *this, isAnonymousOperator); |
| #endif |
| |
| if (RefPtr select = dynamicDowncast<HTMLSelectElement>(node); select && select->usesMenuList() && !select->usesBaseAppearancePicker()) |
| return AccessibilityMenuList::create(AXID::generate(), renderer, *this); |
| |
| // Progress indicator. |
| if (is<RenderProgress>(renderer) || is<RenderMeter>(renderer) |
| || is<HTMLProgressElement>(node) || is<HTMLMeterElement>(node)) |
| return AccessibilityProgressIndicator::create(AXID::generate(), renderer, *this); |
| |
| // input type=range |
| if (is<RenderSlider>(renderer)) |
| return AccessibilitySlider::create(AXID::generate(), renderer, *this); |
| |
| return AccessibilityRenderObject::create(AXID::generate(), renderer, *this); |
| } |
| |
| Ref<AccessibilityNodeObject> AXObjectCache::createFromNode(Node& node) |
| { |
| if (RefPtr element = dynamicDowncast<Element>(node)) { |
| // Lists shouldn't fallthrough to table components, so explicitly create a render object. |
| if (AXListHelpers::isAccessibilityList(*element)) |
| return AccessibilityRenderObject::create(AXID::generate(), *element, *this); |
| if (RefPtr areaElement = dynamicDowncast<HTMLAreaElement>(*element)) |
| return AccessibilityNodeObject::create(AXID::generate(), areaElement.get(), *this); |
| if (is<HTMLProgressElement>(*element) || is<HTMLMeterElement>(*element)) |
| return AccessibilityProgressIndicator::create(AXID::generate(), *element, *this); |
| if (is<SVGElement>(*element)) |
| return AccessibilitySVGObject::create(AXID::generate(), *element, *this); |
| } |
| return AccessibilityRenderObject::create(AXID::generate(), node, *this); |
| } |
| |
| void AXObjectCache::cacheAndInitializeWrapper(AccessibilityObject& newObject, DOMObjectVariant domObject, ShouldAttachWrapper shouldAttach) |
| { |
| AXID axID = newObject.objectID(); |
| |
| WTF::switchOn(domObject, |
| [&] (RenderObject* typedValue) { |
| CheckedPtr node = typedValue->node(); |
| if (!node) |
| m_renderObjectIdMapping.set(*typedValue, axID); |
| else { |
| m_nodeIdMapping.set(*node, axID); |
| m_nodeObjectMapping.set(*node, newObject); |
| } |
| }, |
| [&] (Node* typedValue) { |
| m_nodeIdMapping.set(*typedValue, axID); |
| m_nodeObjectMapping.set(*typedValue, newObject); |
| }, |
| [&] (Widget* typedValue) { m_widgetIdMapping.set(*typedValue, axID); }, |
| [] (auto&) { } |
| ); |
| |
| m_objects.set(axID, newObject); |
| newObject.init(); |
| if (shouldAttach == ShouldAttachWrapper::Yes) |
| attachWrapper(newObject); |
| } |
| |
| AccessibilityObject* AXObjectCache::exportedGetOrCreate(Node* node) |
| { |
| return node ? exportedGetOrCreate(*node) : nullptr; |
| } |
| |
| AccessibilityObject* AXObjectCache::exportedGetOrCreate(Node& node) |
| { |
| return getOrCreate(node, IsPartOfRelation::No); |
| } |
| |
| Document* AXObjectCache::document() const |
| { |
| return m_document.get(); |
| } |
| |
| AccessibilityObject* AXObjectCache::get(Node& node) const |
| { |
| if (CheckedPtr document = dynamicDowncast<Document>(node)) [[unlikely]] |
| return get(document->renderView()); |
| return m_nodeObjectMapping.get(node); |
| } |
| |
| std::optional<AXID> AXObjectCache::getAXID(RenderObject& renderer) const |
| { |
| if (auto* node = renderer.node()) |
| return m_nodeIdMapping.getOptional(*node); |
| return m_renderObjectIdMapping.getOptional(const_cast<RenderObject&>(renderer)); |
| } |
| |
| void AXObjectCache::onLaidOutInlineContent(const RenderBlockFlow& renderBlock) |
| { |
| setDirtyStitchGroups(renderBlock); |
| } |
| |
| AccessibilityObject* AXObjectCache::getOrCreateSlow(Widget& widget) |
| { |
| // `get` for this Widget should've been attempted before calling this method. |
| AX_ASSERT(!get(widget)); |
| |
| RefPtr<AccessibilityObject> newObject; |
| if (auto* scrollView = dynamicDowncast<ScrollView>(widget)) |
| newObject = AccessibilityScrollView::create(AXID::generate(), *scrollView, *this); |
| else if (auto* scrollbar = dynamicDowncast<Scrollbar>(widget)) |
| newObject = AccessibilityScrollbar::create(AXID::generate(), *scrollbar, *this); |
| |
| // Ensure we weren't given an unsupported widget type. |
| AX_ASSERT(newObject); |
| if (!newObject) |
| return nullptr; |
| |
| cacheAndInitializeWrapper(*newObject, &widget); |
| return newObject.unsafeGet(); |
| } |
| |
| AccessibilityObject* AXObjectCache::getOrCreateSlow(Node& node, IsPartOfRelation isPartOfRelation) |
| { |
| // `get` for this Node should've been attempted before calling this method. |
| AX_ASSERT(!get(node)); |
| |
| #if ENABLE_ACCESSIBILITY_LOCAL_FRAME |
| // Easily reproducible on most pages with ITM off. |
| AX_BROKEN_ASSERT(&node.document() == document()); |
| #endif |
| |
| bool isYouTubeReplacement = false; |
| if (CheckedPtr renderer = nodeRendererIsValid(node) ? node.renderer() : nullptr) { |
| if (!renderer->isYouTubeReplacement()) [[likely]] |
| return getOrCreate(*renderer); |
| isYouTubeReplacement = true; |
| } |
| |
| if (CheckedPtr document = dynamicDowncast<Document>(node)) [[unlikely]] |
| return getOrCreate(document->renderView()); |
| |
| RefPtr composedParent = node.parentElementInComposedTree(); |
| if (!composedParent) |
| return nullptr; |
| |
| Ref protectedNode { node }; |
| |
| RefPtr optionElement = dynamicDowncast<HTMLOptionElement>(node); |
| RefPtr optGroupElement = dynamicDowncast<HTMLOptGroupElement>(node); |
| if (optionElement || optGroupElement) { |
| RefPtr select = optionElement |
| ? optionElement->ownerSelectElement() |
| : optGroupElement->ownerSelectElement(); |
| if (!select) |
| return nullptr; |
| RefPtr<AccessibilityObject> object; |
| if (select->usesMenuList() && !select->usesBaseAppearancePicker()) { |
| if (!optionElement || !select->renderer()) |
| return nullptr; |
| object = AccessibilityMenuListOption::create(AXID::generate(), *optionElement, *this); |
| } else |
| object = AccessibilityListBoxOption::create(AXID::generate(), downcast<HTMLElement>(node), *this); |
| cacheAndInitializeWrapper(*object, &node); |
| return object.unsafeGet(); |
| } |
| |
| bool inCanvasSubtree = lineageOfType<HTMLCanvasElement>(*composedParent).first(); |
| if (inCanvasSubtree) { |
| // Don't include objects that are descendants of user agent shadow trees. For example, in this HTML: |
| // <canvas><input type="text"></canvas> |
| // <input type="text"> generates a user agent shadow root to host a <div contenteditable>. |
| // We already handle elements that host user agent shadows specially, so don't include their |
| // descendants (like this <div>) just because they happen to be within <canvas>. |
| if (auto* shadowRoot = composedParent->shadowRoot()) |
| inCanvasSubtree = !shadowRoot->isUserAgentShadowRoot(); |
| } |
| // If node is the target of a relationship or a descendant of one, create an AX object unconditionally. |
| if (isPartOfRelation == IsPartOfRelation::No && !isDescendantOfRelatedNode(node)) { |
| bool insideMeterElement = is<HTMLMeterElement>(*composedParent); |
| RefPtr element = dynamicDowncast<Element>(node); |
| bool hasDisplayContents = element && element->hasDisplayContents(); |
| bool isPopover = element && element->hasAttributeWithoutSynchronization(popoverAttr); |
| bool isAreaElement = is<HTMLAreaElement>(element); |
| // YouTube embeds specifically create a render hierarchy with two elements that share a renderer. |
| // In this instance, we want the <embed> element to associate its node with an AX element, so we need to create one here. |
| bool replacementWillCreateRenderer = isYouTubeReplacement; |
| if (!inCanvasSubtree && !insideMeterElement && !hasDisplayContents && !isPopover && !isNodeFocused(node) && !isAreaElement && !replacementWillCreateRenderer) |
| return nullptr; |
| } |
| |
| // The object may have already been created during relations update. |
| if (RefPtr object = get(node)) |
| return object.unsafeGet(); |
| |
| // Fallback content is only focusable as long as the canvas is displayed and visible. |
| // Update the style before Element::isFocusable() gets called. |
| if (inCanvasSubtree) |
| protect(node.document())->updateStyleIfNeeded(); |
| |
| RefPtr newObject = createFromNode(node); |
| |
| // Will crash later if we have two objects for the same node. |
| AX_ASSERT(!get(node)); |
| cacheAndInitializeWrapper(*newObject, &node); |
| // Compute the object's initial ignored status. |
| newObject->recomputeIsIgnored(); |
| // Sometimes asking isIgnored() will cause the newObject to be deallocated, and then |
| // it will disappear when this function is finished, leading to a use-after-free. |
| if (newObject->isDetached()) |
| return nullptr; |
| return newObject.unsafeGet(); |
| } |
| |
| AccessibilityObject* AXObjectCache::getOrCreate(RenderObject& renderer) |
| { |
| if (renderer.isYouTubeReplacement()) [[unlikely]] |
| return getOrCreate(protect(renderer.node())); |
| |
| if (RefPtr object = get(renderer)) |
| return object.unsafeGet(); |
| |
| // Don't create an object for this renderer if it's being destroyed. |
| if (renderer.beingDestroyed()) |
| return nullptr; |
| |
| // We should never create objects that have dirty layout. Doing so can cause |
| // incorrect accessibility tree updates and also for renderers to be deleted |
| // out from under us, causing memory safety issues (or CheckedPtr crashes if we're lucky). |
| AX_BROKEN_ASSERT(!renderer.needsLayout()); |
| |
| Ref object = createObjectFromRenderer(renderer); |
| |
| // Will crash later if we have two objects for the same renderer. |
| AX_BROKEN_ASSERT(!get(renderer)); |
| |
| cacheAndInitializeWrapper(object.get(), &renderer); |
| // Compute the object's initial ignored status. |
| object->recomputeIsIgnored(); |
| // Sometimes asking isIgnored() will cause the newObject to be deallocated, and then |
| // it will disappear when this function is finished, leading to a use-after-free. |
| if (object->isDetached()) |
| return nullptr; |
| |
| return object.unsafePtr(); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| RefPtr<AXIsolatedTree> AXObjectCache::getOrCreateIsolatedTree() |
| { |
| AXTRACE(makeString("AXObjectCache::getOrCreateIsolatedTree 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| AX_ASSERT(isMainThread()); |
| |
| if (!isIsolatedTreeEnabled()) { |
| AX_ASSERT_NOT_REACHED(); |
| return nullptr; |
| } |
| |
| RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID); |
| if (tree && tree->treeID() == treeID()) |
| return tree; |
| |
| // An AXObjectCache might still exist for a document that's detached from its frame. |
| if (!page()) |
| return nullptr; |
| |
| if (tree) { |
| // The tree belongs to a different document (navigation occurred). |
| // Remove the old tree and create a new one. |
| AXIsolatedTree::removeTreeForFrameID(m_frameID); |
| tree = nullptr; |
| } |
| |
| if (clientIsInTestMode()) [[unlikely]] { |
| // For test clients (LayoutTests / XCTests) build the whole isolated tree synchronously. |
| // This is necessary because tests assume that APIs like accessibleElementById can |
| // immediately retrieve any object. In the future, we may want to consider finding a way |
| // to remove this test-specific path (maybe by making tests wait for the isolated tree |
| // to be built via a new accessibility test harness?) |
| tree = AXIsolatedTree::create(*this); |
| } else { |
| // This method can be called as the result of a client request. Since creating the |
| // isolated tree can take long, especially for large documents, for real clients we |
| // build a temporary "empty" isolated tree consisting only of the ScrollView and the |
| // WebArea objects. Then we schedule building the entire isolated tree on a Timer. |
| tree = AXIsolatedTree::createEmpty(*this); |
| if (!m_buildIsolatedTreeTimer.isActive()) |
| m_buildIsolatedTreeTimer.startOneShot(0_s); |
| } |
| |
| return tree; |
| } |
| |
| void AXObjectCache::initializeIsolatedTreeGeometry() |
| { |
| // Cache the primary display's rect on the geometry manager (its height is exposed to AX clients as |
| // AXPrimaryScreenHeight) and schedule an immediate object-region paint to cache per-object rects. |
| m_geometryManager->initializePrimaryScreenRect(); |
| scheduleObjectRegionsUpdate(true /* scheduleImmediately */); |
| } |
| |
| void AXObjectCache::buildIsolatedTree() |
| { |
| m_buildIsolatedTreeTimer.stop(); |
| |
| // Our document may have been detached from its frame since this timer was scheduled. |
| if (!page()) |
| return; |
| |
| RefPtr tree = AXIsolatedTree::create(*this); |
| |
| if (RefPtr webArea = rootWebArea()) { |
| postPlatformNotification(*webArea, AXNotification::LoadComplete); |
| postPlatformNotification(*webArea, AXNotification::FocusedUIElementChanged); |
| } |
| } |
| |
| void AXObjectCache::setIsolatedTree(Ref<AXIsolatedTree> tree) |
| { |
| AX_ASSERT(isMainThread()); |
| if (RefPtr frame = m_document ? m_document->frame() : nullptr) |
| frame->loader().client().setIsolatedTree(WTF::move(tree)); |
| } |
| #endif |
| |
| AXCoreObject* AXObjectCache::rootObjectForFrame(LocalFrame& frame) |
| { |
| if (!accessibilityEnabled()) |
| return nullptr; |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (isIsolatedTreeEnabled()) { |
| // FIXME: getOrCreateIsolatedTree() asserts isMainThread(), and it seems |
| // expected this function (rootObjectForFrame) can be called on and off |
| // the main-thread based on the !isMainThread() branch below. We should |
| // reconcile this. |
| RefPtr tree = getOrCreateIsolatedTree(); |
| if (!isMainThread()) { |
| if (tree) { |
| tree->applyPendingChanges(); |
| return tree->rootNode(); |
| } |
| return nullptr; |
| } |
| } |
| #endif |
| |
| return getOrCreate(protect(frame.view())); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| RefPtr<AccessibilityScrollView> AXObjectCache::scrollViewForFrame(LocalFrame& frame) |
| { |
| return dynamicDowncast<AccessibilityScrollView>(rootObjectForFrame(frame)); |
| } |
| |
| void AXObjectCache::setFrameInheritedState(LocalFrame& frame, const InheritedFrameState& state) |
| { |
| RefPtr scrollView = scrollViewForFrame(frame); |
| if (!scrollView) |
| return; |
| |
| scrollView->setInheritedFrameState(state); |
| } |
| |
| void AXObjectCache::setFrameGeometry(LocalFrame& frame, const AXFrameGeometry& geometry) |
| { |
| m_frameGeometry = geometry; |
| |
| // Reset to zero to avoid leaving a stale value in the case of a null frame.view(). |
| m_frameViewOriginScrollPosition = { }; |
| if (CheckedPtr view = frame.view()) |
| m_frameViewOriginScrollPosition = IntPoint(view->documentScrollPositionRelativeToViewOrigin()); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->setFrameGeometry(AXFrameGeometry { geometry }, m_frameViewOriginScrollPosition); |
| #endif |
| } |
| |
| const std::optional<AXFrameGeometry>& AXObjectCache::getAndUpdateFrameGeometry() |
| { |
| if (RefPtr page = document()->page()) |
| page->chrome().client().requestFrameScreenPosition(frameID()); |
| |
| return frameGeometry(); |
| } |
| |
| #endif |
| |
| AccessibilityObject* AXObjectCache::create(AccessibilityRole role) |
| { |
| RefPtr<AccessibilityObject> object; |
| switch (role) { |
| case AccessibilityRole::Column: |
| object = AccessibilityTableColumn::create(AXID::generate(), *this); |
| break; |
| case AccessibilityRole::TableHeaderContainer: |
| object = AccessibilityTableHeaderContainer::create(AXID::generate(), *this); |
| break; |
| case AccessibilityRole::RemoteFrame: |
| object = AXRemoteFrame::create(AXID::generate(), *this); |
| break; |
| case AccessibilityRole::LocalFrame: |
| object = AXLocalFrame::create(AXID::generate(), *this); |
| break; |
| case AccessibilityRole::SliderThumb: |
| object = AccessibilitySliderThumb::create(AXID::generate(), *this); |
| break; |
| case AccessibilityRole::MenuListPopup: |
| object = AccessibilityMenuListPopup::create(AXID::generate(), *this); |
| break; |
| case AccessibilityRole::SpinButtonPart: |
| object = AccessibilitySpinButtonPart::create(AXID::generate(), *this); |
| break; |
| default: |
| break; |
| } |
| |
| if (!object) |
| return nullptr; |
| |
| // AccessibilityRole::LocalFrame proxies a child frame's root web area in its parent's |
| // accessibility tree. AccessibilityScrollView::addLocalFrameChild() installs that child |
| // root's wrapper on the AXLocalFrame via setWrapperFrom(); attaching one here would |
| // immediately be discarded. |
| cacheAndInitializeWrapper(*object, /* DOMObjectVariant */ nullptr, role == AccessibilityRole::LocalFrame ? ShouldAttachWrapper::No : ShouldAttachWrapper::Yes); |
| return object.unsafeGet(); |
| } |
| |
| Ref<AccessibilitySpinButton> AXObjectCache::createSpinButton(SpinButtonElement& spinButtonElement) |
| { |
| Ref spinButton = AccessibilitySpinButton::create(AXID::generate(), spinButtonElement, *this); |
| cacheAndInitializeWrapper(spinButton.get()); |
| return spinButton; |
| } |
| |
| void AXObjectCache::remove(AXID axID) |
| { |
| AXTRACE(makeString("AXObjectCache::remove 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| AXLOG(makeString("AXID "_s, axID.loggingString())); |
| |
| RefPtr object = m_objects.take(axID); |
| if (!object) |
| return; |
| |
| SetForScope removingNode(m_isRemovingNode, true); |
| #if PLATFORM(COCOA) |
| if (m_liveRegionManager) |
| m_liveRegionManager->unregisterLiveRegion(axID); |
| #endif |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| unsigned liveRegionsRemoved = m_sortedLiveRegionIDs.removeAll(axID); |
| unsigned webAreasRemoved = m_sortedNonRootWebAreaIDs.removeAll(axID); |
| |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) { |
| tree->queueNodeRemoval(*object); |
| |
| if (liveRegionsRemoved) |
| tree->sortedLiveRegionsDidChange(m_sortedLiveRegionIDs); |
| else if (webAreasRemoved) |
| tree->sortedNonRootWebAreasDidChange(m_sortedNonRootWebAreaIDs); |
| } |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| |
| removeAllRelations(axID); |
| object->detach(AccessibilityDetachmentType::ElementDestroyed); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| m_geometryManager->remove(axID); |
| #endif |
| } |
| |
| void AXObjectCache::remove(RenderObject& renderer) |
| { |
| AXTRACE(makeString("AXObjectCache::remove RenderObject* 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| AX_ASSERT(!renderer.node() || !m_renderObjectIdMapping.contains(renderer)); |
| |
| if (RefPtr node = renderer.node()) { |
| // We only delete objects with nodes when their DOM node is destroyed, not their renderer. |
| if (RefPtr existingObject = dynamicDowncast<AccessibilityRenderObject>(get(*node))) { |
| if (existingObject->setRendererIfNeeded(nullptr)) { |
| // We do need to update the object in response to losing its renderer, though. |
| m_deferredRendererChangedList.add(*existingObject); |
| } |
| } |
| } else |
| remove(m_renderObjectIdMapping.takeOptional(renderer)); |
| } |
| |
| void AXObjectCache::remove(Node& node) |
| { |
| AXTRACE(makeString("AXObjectCache::remove Node& 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| |
| m_nodeObjectMapping.remove(node); |
| remove(m_nodeIdMapping.take(node)); |
| |
| if (CheckedPtr renderer = node.renderer()) |
| remove(*renderer); |
| |
| // If we're in the middle of a cache update, don't modify any of these vectors because we are currently |
| // iterating over them. They will be cleared at the end of the cache update, so not removing them here is fine. |
| if (m_performingDeferredCacheUpdate) { |
| AXLOG("Bailing out before removing node from m_deferred* vectors as we are in the middle of a cache update."); |
| return; |
| } |
| |
| // We cannot use RefPtr here as node's m_deletionHasBegun is true. |
| if (auto* nodeElement = dynamicDowncast<Element>(node)) { |
| m_deferredTextFormControlValue.remove(*nodeElement); |
| m_deferredAttributeChange.removeAllMatching([&node] (const auto& entry) { |
| return entry.element == &node; |
| }); |
| m_modalElements.removeAllMatching([&nodeElement] (const auto& element) { |
| return nodeElement == element.get(); |
| }); |
| m_deferredRecomputeIsIgnoredList.remove(*nodeElement); |
| m_deferredRecomputeTableIsExposedList.remove(*nodeElement); |
| m_deferredSelectedChildredChangedList.remove(*nodeElement); |
| m_deferredModalChangedList.remove(*nodeElement); |
| m_deferredMenuListChange.remove(*nodeElement); |
| m_deferredElementAddedOrRemovedList.remove(*nodeElement); |
| } |
| m_deferredTextChangedList.remove(node); |
| } |
| |
| void AXObjectCache::remove(Widget& view) |
| { |
| remove(m_widgetIdMapping.takeOptional(view)); |
| if (auto* scrollView = dynamicDowncast<ScrollView>(view)) |
| m_deferredScrollbarUpdateChangeList.remove(*scrollView); |
| } |
| |
| void AXObjectCache::handleTextChanged(AccessibilityObject* object) |
| { |
| AXTRACE(makeString("AXObjectCache::handleTextChanged 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| AXLOG(object); |
| |
| if (!object) |
| return; |
| |
| Ref<AccessibilityObject> protectedObject(*object); |
| |
| bool isText = object->isStaticText(); |
| // If this element supports ARIA live regions, or is part of a region with an ARIA editable role, |
| // then notify the AT of changes. |
| bool notifiedNonNativeTextControl = false; |
| for (RefPtr ancestor = object; ancestor; ancestor = ancestor->parentObject()) { |
| if (ancestor->supportsLiveRegion()) |
| postLiveRegionChangeNotification(*ancestor); |
| |
| if (!notifiedNonNativeTextControl && ancestor->isNonNativeTextControl()) { |
| postNotification(ancestor.get(), protect(ancestor->document()).get(), AXNotification::ValueChanged); |
| notifiedNonNativeTextControl = true; |
| } |
| |
| if (isText) { |
| bool dependsOnTextUnderElement = ancestor->dependsOnTextUnderElement(); |
| auto role = ancestor->role(); |
| dependsOnTextUnderElement |= role == AccessibilityRole::Label || role == AccessibilityRole::TextField; |
| |
| // If the starting object is a static text, its underlying text has changed. |
| if (dependsOnTextUnderElement) { |
| // Inform this ancestor its textUnderElement-dependent data is now out-of-date. |
| postNotification(ancestor.get(), nullptr, AXNotification::TextUnderElementChanged); |
| } |
| } |
| |
| // Any objects this ancestor labeled now also need new AccessibilityText. This must run even |
| // when |object| is not static text: a name-source change like aria-label, alt, or title on an |
| // element referenced via aria-labelledby alters the referrer's accessible name just the same. |
| auto labeledObjects = ancestor->labelForObjects(); |
| for (const auto& labeledObject : labeledObjects) |
| postNotification(&downcast<AccessibilityObject>(labeledObject.get()), nullptr, AXNotification::TextChanged); |
| } |
| |
| postNotification(object, protect(object->document()).get(), AXNotification::TextChanged); |
| object->recomputeIsIgnored(); |
| } |
| |
| void AXObjectCache::onRendererCreated(Node& node) |
| { |
| CheckedPtr renderer = node.renderer(); |
| if (!renderer) [[unlikely]] { |
| AX_ASSERT_NOT_REACHED(); |
| return; |
| } |
| |
| if (RefPtr existingObject = dynamicDowncast<AccessibilityRenderObject>(get(node))) { |
| if (existingObject->setRendererIfNeeded(renderer.get())) |
| m_deferredRendererChangedList.add(*existingObject); |
| } |
| } |
| |
| static bool isClickEvent(const AtomString& eventType) |
| { |
| return eventType == eventNames().clickEvent |
| || eventType == eventNames().mousedownEvent |
| || eventType == eventNames().mouseupEvent; |
| } |
| |
| void AXObjectCache::onDragElementChanged(Element* oldElement, Element* newElement) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (oldElement == newElement) |
| return; |
| |
| if (oldElement) |
| postNotification(get(*oldElement), AXNotification::GrabbedStateChanged); |
| |
| if (newElement) |
| postNotification(get(*newElement), AXNotification::GrabbedStateChanged); |
| #else |
| UNUSED_PARAM(oldElement); |
| UNUSED_PARAM(newElement); |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| } |
| |
| void AXObjectCache::onDraggingStarted(Element& dragTarget) |
| { |
| postNotification(&dragTarget, AXNotification::DraggingStarted); |
| } |
| |
| void AXObjectCache::onDraggingEnded(Element& dragTarget) |
| { |
| postNotification(&dragTarget, AXNotification::DraggingEnded); |
| } |
| |
| void AXObjectCache::onDraggingEnteredDropZone(Element& dragTarget) |
| { |
| postNotification(&dragTarget, AXNotification::DraggingEnteredDropZone); |
| } |
| |
| void AXObjectCache::onDraggingExitedDropZone(Element& dragTarget) |
| { |
| postNotification(&dragTarget, AXNotification::DraggingExitedDropZone); |
| } |
| |
| void AXObjectCache::onDraggingDropped(Element& dragTarget) |
| { |
| postNotification(&dragTarget, AXNotification::DraggingDropped); |
| } |
| |
| void AXObjectCache::onEventListenerAdded(Node& node, const AtomString& eventType) |
| { |
| handleClickHandlerChanged(node, eventType); |
| } |
| |
| void AXObjectCache::onEventListenerRemoved(Node& node, const AtomString& eventType) |
| { |
| handleClickHandlerChanged(node, eventType); |
| } |
| |
| void AXObjectCache::handleClickHandlerChanged(Node& node, const AtomString& eventType) |
| { |
| if (!isClickEvent(eventType)) |
| return; |
| |
| RefPtr object = get(node); |
| if (!object) |
| return; |
| |
| // A hrefless anchor is exposed as a link only when it has a click handler, so adding or removing |
| // one can change its role. (An anchor with an href is a link regardless of its click handlers.) |
| RefPtr anchor = dynamicDowncast<Element>(node); |
| if ((is<HTMLAnchorElement>(anchor.get()) || is<SVGAElement>(anchor.get())) && !anchor->isLink()) |
| object->updateRole(); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->queueNodeUpdate(object->objectID(), { AXProperty::HasClickHandler }); |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| } |
| |
| void AXObjectCache::updateLoadingProgress(double newProgressValue) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| ASSERT_WITH_MESSAGE(newProgressValue >= 0 && newProgressValue <= 1, "unexpected loading progress value: %f", newProgressValue); |
| // Sometimes the isolated tree hasn't been created by the time we get loading progress updates, |
| // so cache this value in the AXObjectCache too so we can give it to the tree upon creation. |
| m_loadingProgress = newProgressValue; |
| if (auto tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->updateLoadingProgress(newProgressValue); |
| #else |
| UNUSED_PARAM(newProgressValue); |
| #endif |
| } |
| |
| void AXObjectCache::onTopDocumentLoaded(RenderObject& renderer) |
| { |
| postDeferredNotification(renderer, AXNotification::NewDocumentLoadComplete); |
| } |
| |
| void AXObjectCache::onNonTopDocumentLoaded(RenderObject& renderer) |
| { |
| // AXLoadComplete can only be posted on the top document, so if it's a document |
| // in an iframe that just finished loading, post AXLayoutComplete instead. |
| postDeferredNotification(renderer, AXNotification::LayoutComplete); |
| } |
| |
| void AXObjectCache::onAutocorrectionOccured(Element& element) |
| { |
| postNotification(&element, AXNotification::AutocorrectionOccured); |
| } |
| |
| void AXObjectCache::onEditableTextValueChanged(Node& node) |
| { |
| postNotification(&node, AXNotification::ValueChanged, PostTarget::ObservableParent); |
| } |
| |
| void AXObjectCache::onDocumentInitialFocus(Node& node) |
| { |
| postNotification(&node, AXNotification::FocusedUIElementChanged); |
| } |
| |
| void AXObjectCache::onLayoutComplete(RenderObject& renderObject) |
| { |
| postNotification(&renderObject, AXNotification::LayoutComplete); |
| } |
| |
| void AXObjectCache::handleAllDeferredChildrenChanged() |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| RefPtr<AXIsolatedTree> tree; |
| if (!m_deferredChildrenChangedList.isEmpty()) |
| tree = AXIsolatedTree::treeForFrameID(m_frameID); |
| #endif |
| |
| // Because m_deferredChildrenChangedList can be appended to while we iterate, we have to process |
| // this list in two steps (repeatedly until empty) to ensure the isolated tree is updated correctly. |
| // Specifically, it's possible for an entry to be appended during AXIsolatedTree::updateChildren (e.g. |
| // due to an object re-computing is-ignored to a different value). |
| while (!m_deferredChildrenChangedList.isEmpty()) { |
| // Perform AXObjectCache::handleChildrenChanged on all objects first, then update the isolated tree |
| // afterwards. Doing it in two steps prevents thrashing m_subtreeDirty (i.e. AXObjectCache::handleChildrenChanged |
| // setting m_subtreeDirty on some high-in-the-tree object, clearing that during AXIsolatedTree::updateChildren, |
| // then having it set again by the next children-changed entry, repeat). |
| auto deferredChildrenChangedList = std::exchange(m_deferredChildrenChangedList, { }); |
| for (auto& object : deferredChildrenChangedList) |
| handleChildrenChanged(object); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (tree) |
| tree->updateChildrenForObjects(deferredChildrenChangedList); |
| #endif |
| |
| #if !PLATFORM(COCOA) |
| // Neither the MAC nor IOS_FAMILY ports map AXChildrenChanged to a platform notification. |
| for (auto& object : deferredChildrenChangedList) |
| postPlatformNotification(object, AXNotification::ChildrenChanged); |
| #endif |
| } |
| |
| // These entries only suppress the children-changed raised by the re-render that recorded them, so |
| // they must not survive into subsequent, genuine mutations of the same live region. |
| m_deferredReRenderedContent.clear(); |
| m_reRenderedContentAndAncestors = std::nullopt; |
| } |
| |
| void AXObjectCache::handleChildrenChanged(AccessibilityObject& object) |
| { |
| AXTRACE("AXObjectCache::handleChildrenChanged"_s); |
| AXLOG(object); |
| |
| // Handle MenuLists and MenuListPopups as special cases. |
| if (is<AccessibilityMenuList>(object)) { |
| const auto& children = object.unignoredChildren(/* updateChildrenIfNeeded */ false); |
| if (children.isEmpty()) |
| return; |
| |
| AX_ASSERT(children.size() == 1); |
| handleChildrenChanged(downcast<AccessibilityObject>(children[0].get())); |
| } else if (auto* menuListPopup = dynamicDowncast<AccessibilityMenuListPopup>(object)) { |
| menuListPopup->handleChildrenChanged(); |
| return; |
| } else if (auto* nodeObject = dynamicDowncast<AccessibilityNodeObject>(object); nodeObject && nodeObject->isTable()) |
| deferRecomputeTableCellSlots(*nodeObject); |
| else if (auto* parentTable = dynamicDowncast<AccessibilityNodeObject>(object.parentTableIfExposedTableRow())) |
| deferRecomputeTableCellSlots(*parentTable); |
| else if (auto* scrollView = dynamicDowncast<AccessibilityScrollView>(object)) { |
| #if ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| if (scrollView->role() == AccessibilityRole::FrameHost) { |
| // For FrameHost scroll views, propagate childrenChanged to the parent |
| // iframe element so the ancestor chain walk runs and updates the |
| // isolated tree. |
| childrenChanged(protect(scrollView->parentObject()).get()); |
| return; |
| } |
| #endif // ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| |
| // When the children of an iframe change, e.g., because its visibility changes, |
| // then we need to dirty the web area's subtree since the scroll area doesn't |
| // have a node nor renderer, thus, failing the check below and returning early. |
| |
| // Only do this when the web area is not the root web area, as this indicates |
| // we are in an iframe. |
| if (RefPtr webArea = scrollView->webAreaObject(); webArea && webArea != rootWebArea()) { |
| webArea->setNeedsToUpdateSubtree(); |
| webArea->setNeedsToUpdateChildren(); |
| } |
| } |
| |
| if (!object.node() && !object.renderer()) |
| return; |
| |
| // Should make the subtree dirty so that everything below will be updated correctly. |
| object.setNeedsToUpdateSubtree(); |
| |
| object.recomputeIsIgnored(); |
| |
| if (auto* optionElement = dynamicDowncast<HTMLOptionElement>(object.node()); optionElement && optionElement->belongsToBaseAppearancePicker()) { |
| // When a base-appearance select option's children change, its text descendants may need to |
| // change their is-ignored state. Text is only exposed when the option has complex content |
| // (non-text descendants like buttons or links), so adding or removing such elements |
| // toggles can change whether text nodes are ignored. |
| Accessibility::enumerateDescendantsIncludingIgnored<AXCoreObject>(object, /* includeSelf */ false, [] (auto& descendant) { |
| if (descendant.isStaticText()) |
| downcast<AccessibilityObject>(descendant).recomputeIsIgnored(); |
| }); |
| } |
| |
| // Go up the existing ancestors chain and fire the appropriate notifications. |
| bool shouldUpdateParent = true; |
| bool foundTableCaption = false; |
| for (RefPtr parent = object; parent; parent = parent->parentObject()) { |
| if (shouldUpdateParent) |
| parent->setNeedsToUpdateChildren(); |
| |
| // If this object supports ARIA live regions, then notify AT of changes. |
| // This notification needs to be sent even when the screen reader has not accessed this live region since the last update. |
| // Sometimes this function can be called many times within a short period of time, leading to posting too many AXLiveRegionChanged notifications. |
| // To fix this, we use a timer to make sure we only post one notification for the children changes within a pre-defined time interval. |
| if (parent->supportsLiveRegion() && !isReRenderedContent(*parent)) |
| postLiveRegionChangeNotification(*parent); |
| |
| // If this object is an ARIA text control, notify that its value changed. |
| if (parent->isNonNativeTextControl()) { |
| postNotification(parent.get(), protect(parent->document()).get(), AXNotification::ValueChanged); |
| |
| // Do not let any ancestor of an editable object update its children. |
| shouldUpdateParent = false; |
| } |
| |
| if (parent->isLabel()) { |
| // A label's descendant was added or removed. Update its LabelFor relationships. |
| handleLabelChanged(parent.get()); |
| } |
| |
| for (const auto& describedObject : parent->descriptionForObjects()) |
| postNotification(&downcast<AccessibilityObject>(describedObject.get()), nullptr, AXNotification::ExtendedDescriptionChanged); |
| |
| if (parent->hasElementName(ElementName::HTML_caption)) |
| foundTableCaption = true; |
| else if (foundTableCaption && parent->isTable()) { |
| postNotification(parent.get(), nullptr, AXNotification::TextChanged); |
| foundTableCaption = false; |
| } |
| } |
| |
| // The role of list objects is dependent on their children, so we'll need to re-compute it here. |
| if (object.isAccessibilityList()) |
| object.updateRole(); |
| } |
| |
| void AXObjectCache::handleRecomputeCellSlots(AccessibilityNodeObject& axTable) |
| { |
| axTable.setCellSlotsDirty(); |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(axTable, AXNotification::CellSlotsChanged); |
| #endif |
| } |
| |
| void AXObjectCache::onRemoteFrameInitialized(AXRemoteFrame& remoteFrame) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(remoteFrame, AXProperty::RemoteFramePlatformElement); |
| #else |
| UNUSED_PARAM(remoteFrame); |
| #endif |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| void AXObjectCache::handleRowspanChanged(AccessibilityNodeObject& axCell) |
| { |
| updateIsolatedTree(axCell, AXNotification::RowSpanChanged); |
| } |
| #endif |
| |
| const Vector<AXStitchGroup>* AXObjectCache::stitchGroupsOwnedBy(AccessibilityObject& object) |
| { |
| CheckedPtr renderBlockFlow = dynamicDowncast<RenderBlockFlow>(object.renderer()); |
| if (!renderBlockFlow || renderBlockFlow->beingDestroyed()) |
| return nullptr; |
| |
| const auto& groups = m_stitchGroups.ensure(*renderBlockFlow, [&] { |
| return object.stitchGroups(); |
| }).iterator->value; |
| |
| return groups.isEmpty() ? nullptr : &groups; |
| } |
| |
| void AXObjectCache::setDirtyStitchGroups(const RenderBlock& renderBlock) |
| { |
| if (std::optional groups = m_stitchGroups.takeOptional(renderBlock)) { |
| UNUSED_PARAM(groups); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (std::optional axID = getAXID(const_cast<RenderBlock&>(renderBlock))) { |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->queueNodeUpdate(*axID, { AXProperty::StitchGroups }); |
| } |
| #endif |
| } |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| void AXObjectCache::onTextRunsChanged(const RenderObject& renderer) |
| { |
| if (is<RenderInline>(renderer) || is<RenderListOutsideMarker>(renderer)) { |
| // Fast-path exit for common renderers that will never produce text runs. |
| return; |
| } |
| |
| if (std::optional axID = getAXID(const_cast<RenderObject&>(renderer))) { |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->queueNodeUpdate(*axID, { AXProperty::TextRuns }); |
| } |
| } |
| #endif |
| |
| void AXObjectCache::handleMenuOpened(Element& element) |
| { |
| if (!element.renderer() || !hasRole(element, "menu"_s)) { |
| // FIXME: Doesn't this early-return mean we are going to handle display:contents menus incorrectly? |
| return; |
| } |
| |
| postNotification(protect(getOrCreate(element)), protect(document()).get(), AXNotification::MenuOpened); |
| } |
| |
| void AXObjectCache::handleLiveRegionCreated(Element& element) |
| { |
| if (!element.renderer()) { |
| // FIXME: Doesn't this early-return mean we are going to handle display:contents live regions incorrectly? |
| return; |
| } |
| |
| auto liveRegionStatus = element.attributeWithoutSynchronization(aria_liveAttr); |
| if (liveRegionStatus.isEmpty()) { |
| const AtomString& ariaRole = element.attributeWithoutSynchronization(roleAttr); |
| if (!ariaRole.isEmpty()) |
| liveRegionStatus = AtomString { AXCoreObject::defaultLiveRegionStatusForRole(AccessibilityObject::ariaRoleToWebCoreRole(ariaRole)) }; |
| } |
| |
| if (AXCoreObject::liveRegionStatusIsEnabled(liveRegionStatus)) { |
| RefPtr axObject = getOrCreate(element); |
| if (!axObject) |
| return; |
| |
| #if PLATFORM(COCOA) |
| if (m_liveRegionManager) { |
| m_liveRegionManager->registerLiveRegion(*axObject, true); |
| } |
| #endif |
| |
| #if PLATFORM(MAC) |
| deferSortForNewLiveRegion(*axObject); |
| #endif // PLATFORM(MAC) |
| |
| #if PLATFORM(COCOA) |
| if (!m_liveRegionManager) |
| postNotification(axObject.get(), protect(document()).get(), AXNotification::LiveRegionCreated); |
| #endif |
| } |
| } |
| |
| #if PLATFORM(COCOA) |
| void AXObjectCache::initializeLiveRegionManager() |
| { |
| if (!m_liveRegionManager || m_liveRegionManagerInitialized) |
| return; |
| |
| m_liveRegionManagerInitialized = true; |
| |
| RefPtr current = rootWebArea(); |
| while ((current = current ? downcast<AccessibilityObject>(current->nextInPreOrder()) : nullptr)) { |
| if (current->supportsLiveRegion()) |
| m_liveRegionManager->registerLiveRegion(*current); |
| } |
| } |
| #endif |
| |
| void AXObjectCache::deferElementAddedOrRemoved(Element* element) |
| { |
| if (!element) |
| return; |
| |
| m_deferredElementAddedOrRemovedList.add(*element); |
| if (isModalElement(*element)) |
| deferModalChange(*element); |
| |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| void AXObjectCache::deferAddUnconnectedNode(AccessibilityObject& axObject) |
| { |
| m_deferredUnconnectedObjects.add(axObject); |
| |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| #endif |
| |
| AccessibilityObject* AXObjectCache::getIncludingAncestors(RenderObject& renderer) const |
| { |
| for (CheckedPtr current = &renderer; current; current = current->parent()) { |
| if (RefPtr object = get(*current)) |
| return object.unsafeGet(); |
| } |
| return nullptr; |
| } |
| |
| void AXObjectCache::childrenChanged(RenderObject& renderer, RenderObject* changedChild) |
| { |
| if (renderer.isAnonymous()) { |
| // Don't drop a children-changed event if we can't |get| the given renderer if the renderer is anonymous. |
| // Sometimes the only children-changed we get from the render tree for some subtrees is for an anonymous renderer, |
| // so if we just drop it, we can have a stale accessibility tree. This problem is specific to anonymous renderers |
| // because we walk the DOM when building the accessibility tree, so it's unlikely that we will have created |
| // an accessibility object for this renderer (since it only exists in the render tree). |
| // |
| // Children-changed events associated with a DOM node are safe to drop if |get| fails, since some other |
| // element that does have an accessibility object must also get a children-changed event. The same is |
| // not always true for anonymous renderers, hence this branch. |
| // |
| // This behavior comes from a bug on a real webpage that I unfortunately couldn't figure out how to distill |
| // into a layout test. |
| childrenChanged(protect(getIncludingAncestors(renderer))); |
| } else |
| childrenChanged(protect(get(renderer))); |
| |
| if (changedChild) |
| deferElementAddedOrRemoved(protect(dynamicDowncast<Element>(changedChild->node()))); |
| } |
| |
| void AXObjectCache::childrenChanged(AccessibilityObject* object) |
| { |
| if (!object) |
| return; |
| m_deferredChildrenChangedList.add(*object); |
| |
| // Adding or removing rows from a table can cause it to change from layout table to AX data table and vice versa, so queue up recomputation of that for the parent table. |
| if (RefPtr tableElement = dynamicDowncast<HTMLTableElement>(object->element())) |
| deferRecomputeTableIsExposed(const_cast<HTMLTableElement*>(tableElement.get())); |
| else if (RefPtr tableSectionElement = dynamicDowncast<HTMLTableSectionElement>(object->element())) |
| deferRecomputeTableIsExposed(const_cast<HTMLTableElement*>(tableSectionElement->findParentTable().get())); |
| |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| |
| void AXObjectCache::valueChanged(Element& element) |
| { |
| postNotification(&element, AXNotification::ValueChanged); |
| |
| for (RefPtr ancestor = get(element); ancestor; ancestor = ancestor->parentObject()) { |
| if (ancestor->supportsLiveRegion()) { |
| postLiveRegionChangeNotification(*ancestor); |
| break; |
| } |
| } |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| void AXObjectCache::columnIndexChanged(AccessibilityObject& object) |
| { |
| postNotification(object, AXNotification::ColumnIndexChanged); |
| } |
| |
| void AXObjectCache::rowIndexChanged(AccessibilityObject& object) |
| { |
| postNotification(object, AXNotification::RowIndexChanged); |
| } |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| |
| void AXObjectCache::notificationPostTimerFired() |
| { |
| AXTRACE(makeString("AXObjectCache::notificationPostTimerFired 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| // During LayoutTests, accessibility may be disabled between the time the notifications are queued and the timer fires. |
| // Thus check here and return if accessibility is disabled. |
| if (!accessibilityEnabled()) |
| return; |
| |
| RefPtr document = m_document.get(); |
| m_notificationPostTimer.stop(); |
| |
| if (!document || document->renderTreeState() != Document::RenderTreeState::Built) |
| return; |
| |
| // 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 = std::exchange(m_notificationsToPost, { }); |
| // The pending-LayoutComplete coalescing set tracks entries in m_notificationsToPost, so clear it in |
| // lockstep with draining that vector. Any LayoutComplete posted during this flush belongs to the next batch. |
| m_pendingLayoutCompleteObjectIDs.clear(); |
| |
| // Filter out the notifications that are not going to be posted to platform clients. |
| Vector<std::pair<Ref<AccessibilityObject>, AXNotificationWithData>> notificationsToPost; |
| notificationsToPost.reserveInitialCapacity(notifications.size()); |
| for (auto& note : notifications) { |
| if (protect(note.first)->isDetached() || !note.first->axObjectCache()) |
| continue; |
| |
| #if ASSERT_ENABLED |
| // 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 (auto* renderObject = dynamicDowncast<AccessibilityRenderObject>(note.first.get())) { |
| if (auto* renderer = renderObject->renderer()) |
| AX_ASSERT(!renderer->view().frameView().layoutContext().layoutState()); |
| } |
| #endif |
| |
| if (note.second.notification == AXNotification::MenuOpened) { |
| // Only notify if the object is in fact a menu. |
| protect(note.first)->updateChildrenIfNecessary(); |
| if (note.first->role() != AccessibilityRole::Menu) |
| continue; |
| } |
| |
| notificationsToPost.append(WTF::move(note)); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(notificationsToPost); |
| // We're going to post platform notifications, so make sure to publish isolated tree |
| // changes so we provide up-to-date information. |
| processQueuedIsolatedNodeUpdates(); |
| #endif |
| |
| for (const auto& note : notificationsToPost) { |
| WTF::switchOn(note.second.data, |
| [&](const AriaNotifyData& data) { |
| postPlatformARIANotifyNotification(note.first, data); |
| }, |
| #if PLATFORM(COCOA) |
| [&](const LiveRegionAnnouncementData& data) { |
| postPlatformLiveRegionNotification(note.first, data); |
| }, |
| #endif |
| [&](std::monostate) { |
| postPlatformNotification(note.first, note.second.notification); |
| } |
| ); |
| } |
| } |
| |
| #if PLATFORM(COCOA) |
| void AXObjectCache::setShouldRepostNotificationsForTests(bool value) |
| { |
| gShouldRepostNotificationsForTests = value; |
| } |
| #endif |
| |
| void AXObjectCache::enqueueNotificationToPost(Ref<AccessibilityObject>&& object, AXNotificationWithData&& notification) |
| { |
| // JavaScript can starve the zero-delay m_notificationPostTimer by forcing layout (or otherwise |
| // mutating the tree) in a loop without ever returning to the run loop, which would let |
| // m_notificationsToPost grow without bound. LayoutComplete notifications -- by far the most common |
| // offender -- are coalesced per object in postNotification() so that case can't trigger this issue. |
| // This cap is a backstop for every other notification type. If it is ever hit, that notification is |
| // being posted in an unbounded loop and needs its own coalescing strategy like LayoutComplete's, so |
| // assert to surface it while degrading gracefully (dropping the notification) in release rather than |
| // overflowing the Vector and crashing the WebContent process. |
| static constexpr size_t maxNotificationsToPost = 100000; |
| if (m_notificationsToPost.size() >= maxNotificationsToPost) [[unlikely]] { |
| AX_ASSERT_NOT_REACHED(); |
| return; |
| } |
| |
| m_notificationsToPost.append(std::make_pair(WTF::move(object), WTF::move(notification))); |
| if (!m_notificationPostTimer.isActive()) |
| m_notificationPostTimer.startOneShot(0_s); |
| } |
| |
| static std::optional<Seconds>& announcementTranslationTimeoutOverrideForTesting() |
| { |
| static NeverDestroyed<std::optional<Seconds>> override; |
| return override; |
| } |
| |
| void AXObjectCache::setAnnouncementTranslationTimeoutForTesting(std::optional<Seconds> timeout) |
| { |
| announcementTranslationTimeoutOverrideForTesting() = timeout; |
| } |
| |
| Seconds AXObjectCache::announcementTranslationTimeout() |
| { |
| // Long enough for an on-device translation of a short string, short enough that a stalled |
| // translation service does not leave the user waiting on an announcement. |
| static constexpr Seconds defaultAnnouncementTranslationTimeout = 250_ms; |
| return announcementTranslationTimeoutOverrideForTesting().value_or(defaultAnnouncementTranslationTimeout); |
| } |
| |
| void AXObjectCache::translateAnnouncementThenAssemble(Ref<AccessibilityObject>&& object, Vector<String>&& segments, Function<void(Vector<String>&&, const String&)>&& assemble) |
| { |
| RefPtr document = m_document.get(); |
| RefPtr page = document ? document->page() : nullptr; |
| |
| bool needsTranslation = false; |
| String targetLocale; |
| if (page) { |
| // Every announcement in a translated rendering needs translating. |
| targetLocale = page->displayedTranslationLocaleIdentifier(); |
| needsTranslation = !targetLocale.isEmpty(); |
| } |
| |
| if (!needsTranslation && m_pendingAnnouncements.isEmpty()) { |
| assemble(WTF::move(segments), { }); |
| return; |
| } |
| |
| static constexpr size_t maxPendingAnnouncements = 64; |
| if (m_pendingAnnouncements.size() >= maxPendingAnnouncements) [[unlikely]] { |
| // If we hit the pending announcements cap (e.g. translation is running slowly, and / or |
| // there is a high volume of announcements), release the oldest one untranslated rather |
| // than dropping it. Losing an announcement is worse than announcing it in the original language. |
| AX_ASSERT_NOT_REACHED(); |
| auto oldest = m_pendingAnnouncements.takeFirst(); |
| if (!oldest.object->isDetached() && oldest.object->axObjectCache()) |
| oldest.assembleAndEnqueue(WTF::move(oldest.segments), { }); |
| } |
| |
| uint64_t requestID = needsTranslation ? ++m_nextAnnouncementTranslationRequestID : 0; |
| m_pendingAnnouncements.append(PendingAnnouncement { |
| WTF::move(object), |
| segments, |
| needsTranslation ? targetLocale : String { }, |
| requestID, |
| MonotonicTime::now() + announcementTranslationTimeout(), |
| WTF::move(assemble) |
| }); |
| |
| if (needsTranslation) { |
| page->chrome().client().translateAccessibilityAnnouncementStrings(segments, targetLocale, |
| [weakCache = WeakPtr { *this }, requestID](Vector<String>&& translatedSegments) mutable { |
| if (CheckedPtr cache = weakCache.get()) |
| cache->announcementTranslationDidComplete(requestID, WTF::move(translatedSegments)); |
| }); |
| } |
| |
| scheduleAnnouncementTimeoutTimerIfNeeded(); |
| } |
| |
| void AXObjectCache::deferReRenderedContent(Node& node) |
| { |
| RefPtr document = m_document.get(); |
| RefPtr page = document ? document->page() : nullptr; |
| if (!page || !page->isPresentingMachineTranslation()) { |
| // Re-rendered content is only relevant when page-level translation |
| // is active (the "re-rendering" is the replacement of the original |
| // DOM text with the translated version). |
| return; |
| } |
| |
| m_deferredReRenderedContent.add(node); |
| // The ancestor closure was built from a smaller set, so it no longer answers correctly. |
| m_reRenderedContentAndAncestors = std::nullopt; |
| } |
| |
| |
| bool AXObjectCache::isReRenderedContent(const AccessibilityObject& object) const |
| { |
| if (m_deferredReRenderedContent.isEmptyIgnoringNullReferences()) |
| return false; |
| |
| RefPtr document = m_document.get(); |
| RefPtr page = document ? document->page() : nullptr; |
| if (!page || !page->isPresentingMachineTranslation()) { |
| // Only relevant when page-level translation is happening -- see similar comment |
| // in AXObjectCache::deferReRenderedContent. |
| return false; |
| } |
| |
| // m_deferredReRenderedContent holds the containers whose text was swapped out, recorded by |
| // deferReRenderedContent(). The children-changed notification is raised either on such a |
| // container or on a live region ancestor of one, so both directions have to match. |
| RefPtr candidateNode = object.node(); |
| if (!candidateNode) |
| return false; |
| |
| if (!m_reRenderedContentAndAncestors) { |
| // Build a one-time cache (m_reRenderedContentAndAncestors) capturing whether any re-rendered |
| // node or any ancestor of one is a given node, so each query is a set lookup rather than an |
| // ancestor walk per m_deferredReRenderedContent member. |
| m_reRenderedContentAndAncestors.emplace(); |
| for (Ref reRenderedNode : m_deferredReRenderedContent) { |
| for (RefPtr node = reRenderedNode.get(); node; node = node->parentNode()) { |
| if (!m_reRenderedContentAndAncestors->add(*node).isNewEntry) { |
| // Stop when we found an ancestor that was already present, since everything above it |
| // was already added by an earlier m_deferredReRenderedContent member. |
| break; |
| } |
| } |
| } |
| } |
| |
| return m_reRenderedContentAndAncestors->contains(*candidateNode); |
| } |
| |
| void AXObjectCache::announcementTranslationDidComplete(uint64_t requestID, Vector<String>&& translatedSegments) |
| { |
| AX_ASSERT(isMainThread()); |
| |
| auto iterator = m_pendingAnnouncements.findIf([&](auto& entry) { |
| return entry.translationRequestID == requestID; |
| }); |
| // Already force-completed by a timeout or an overflow. |
| if (iterator == m_pendingAnnouncements.end()) |
| return; |
| |
| auto& entry = *iterator; |
| entry.translationRequestID = 0; |
| |
| // A short or oversized reply means the client could not translate these strings, |
| // so keep the originals, and with them the original language. |
| if (translatedSegments.size() == entry.segments.size()) |
| entry.segments = WTF::move(translatedSegments); |
| else |
| entry.targetLocale = { }; |
| |
| flushPendingAnnouncements(); |
| } |
| |
| void AXObjectCache::flushPendingAnnouncements() |
| { |
| // Releasing an entry only enqueues a notification to post on a zero-delay timer, so nothing here |
| // re-enters this function. That matters: an inner flush would release later entries while an |
| // outer one was still draining, which is exactly the ordering this queue exists to preserve. |
| while (!m_pendingAnnouncements.isEmpty() && !m_pendingAnnouncements.first().translationRequestID) { |
| auto entry = m_pendingAnnouncements.takeFirst(); |
| if (entry.object->isDetached() || !entry.object->axObjectCache()) |
| continue; |
| entry.assembleAndEnqueue(WTF::move(entry.segments), entry.targetLocale); |
| } |
| |
| scheduleAnnouncementTimeoutTimerIfNeeded(); |
| } |
| |
| void AXObjectCache::scheduleAnnouncementTimeoutTimerIfNeeded() |
| { |
| if (m_pendingAnnouncements.isEmpty()) { |
| m_pendingAnnouncementTimeoutTimer.stop(); |
| return; |
| } |
| |
| if (m_pendingAnnouncementTimeoutTimer.isActive()) |
| return; |
| |
| // We only need to arm the timer based on the first entries deadline, since |
| // we re-arm the timer as subsequent entries (with their own deadline) are drained. |
| auto delay = std::max(0_s, m_pendingAnnouncements.first().deadline - MonotonicTime::now()); |
| m_pendingAnnouncementTimeoutTimer.startOneShot(delay); |
| } |
| |
| void AXObjectCache::pendingAnnouncementTimeoutTimerFired() |
| { |
| auto now = MonotonicTime::now(); |
| for (auto& entry : m_pendingAnnouncements) { |
| if (entry.deadline > now) |
| break; |
| // Give up waiting and announce the original text in the original language. |
| entry.translationRequestID = 0; |
| entry.targetLocale = { }; |
| } |
| |
| flushPendingAnnouncements(); |
| } |
| |
| void AXObjectCache::postNotification(RenderObject* renderer, AXNotification notification, PostTarget postTarget) |
| { |
| if (!renderer) |
| return; |
| |
| stopCachingComputedObjectAttributes(); |
| |
| // Get an accessibility object that already exists. One should not be created here |
| // because a render update may be in progress and creating an AX object can re-trigger a layout |
| RefPtr<AccessibilityObject> object = get(*renderer); |
| while (!object && renderer) { |
| renderer = renderer->parent(); |
| object = get(renderer); |
| } |
| |
| if (!renderer) |
| return; |
| |
| postNotification(object.get(), protect(renderer->document()).ptr(), notification, postTarget); |
| } |
| |
| void AXObjectCache::postNotification(Node* node, AXNotification notification, PostTarget postTarget) |
| { |
| RefPtr axNode = node; |
| if (!axNode) |
| return; |
| |
| stopCachingComputedObjectAttributes(); |
| |
| // Get an accessibility object that already exists. One should not be created here |
| // because a render update may be in progress and creating an AX object can re-trigger a layout |
| RefPtr object = get(*axNode); |
| while (!object && axNode) { |
| axNode = axNode->parentNode(); |
| object = get(axNode.get()); |
| } |
| |
| if (!axNode) |
| return; |
| |
| postNotification(object.get(), protect(axNode->document()).ptr(), notification, postTarget); |
| } |
| |
| void AXObjectCache::postNotification(AccessibilityObject* object, Document* document, AXNotification notification, PostTarget postTarget) |
| { |
| AXTRACE(makeString("AXObjectCache::postNotification 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| AXLOG(std::make_pair(object, notification)); |
| AX_ASSERT(isMainThread()); |
| |
| stopCachingComputedObjectAttributes(); |
| |
| RefPtr axObject = object; |
| if (axObject && postTarget == PostTarget::ObservableParent) |
| axObject = axObject->observableObject(); |
| |
| if (!axObject && document) |
| axObject = get(document->renderView()); |
| |
| if (!axObject) |
| return; |
| |
| #if PLATFORM(COCOA) |
| if (notification == AXNotification::ValueChanged |
| && enqueuePasswordNotification(*axObject, { })) |
| return; |
| #endif |
| |
| if (notification == AXNotification::LayoutComplete) { |
| // To avoid needlessly accumulating large amounts of redundant layout-complete notifications, |
| // e.g. from JS that forces synchronous layout in a loop, coalesce them on a per-object basis. |
| if (!m_pendingLayoutCompleteObjectIDs.add(axObject->objectID()).isNewEntry) |
| return; |
| } |
| |
| enqueueNotificationToPost(axObject.releaseNonNull(), notification); |
| } |
| |
| void AXObjectCache::postNotification(AccessibilityObject& object, AXNotification notification) |
| { |
| AXTRACE(makeString("AXObjectCache::postNotification 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| auto dataNotification = AXNotificationWithData(notification); |
| AXLOG(std::make_pair(Ref { object }, dataNotification)); |
| AX_ASSERT(isMainThread()); |
| |
| stopCachingComputedObjectAttributes(); |
| |
| #if PLATFORM(COCOA) |
| if (notification == AXNotification::ValueChanged |
| && enqueuePasswordNotification(object, { })) |
| return; |
| #endif |
| |
| enqueueNotificationToPost(Ref { object }, WTF::move(dataNotification)); |
| } |
| |
| void AXObjectCache::postARIANotifyNotification(Node& node, const String& announcement, const AriaNotifyOptions& options) |
| { |
| RefPtr<AccessibilityObject> object = getOrCreate(node); |
| if (!object) |
| return; |
| |
| auto priority = NotifyPriority::Normal; |
| if (options.priority) { |
| switch (*options.priority) { |
| case AriaNotifyOptions::NotificationPriority::Normal: |
| priority = NotifyPriority::Normal; |
| break; |
| case AriaNotifyOptions::NotificationPriority::High: |
| priority = NotifyPriority::High; |
| break; |
| } |
| } |
| |
| auto interruptBehavior = InterruptBehavior::None; |
| if (options.interrupt) { |
| switch (*options.interrupt) { |
| case AriaNotifyOptions::NotificationInterrupt::None: |
| interruptBehavior = InterruptBehavior::None; |
| break; |
| case AriaNotifyOptions::NotificationInterrupt::All: |
| interruptBehavior = InterruptBehavior::All; |
| break; |
| case AriaNotifyOptions::NotificationInterrupt::Pending: |
| interruptBehavior = InterruptBehavior::Pending; |
| break; |
| } |
| } |
| |
| auto sourceLanguage = object->languageIncludingAncestors(); |
| |
| // Assembles and enqueues the notification once the announcement text is final, which is true when either: |
| // 1. No translation is needed, and thus can be announced immediately. |
| // 2. The translation request returns the new text. |
| // 3. The translation request times out, and the original text is used. |
| auto assemble = [weakCache = WeakPtr { *this }, object, priority, interruptBehavior, sourceLanguage](Vector<String>&& segments, const String& language) mutable { |
| if (segments.isEmpty()) |
| return; |
| CheckedPtr cache = weakCache.get(); |
| if (!cache) |
| return; |
| |
| cache->enqueueNotificationToPost(Ref { *object }, AXNotificationWithData(AXNotification::ARIANotify, |
| AriaNotifyData { WTF::move(segments[0]), priority, interruptBehavior, language.isEmpty() ? sourceLanguage : language })); |
| }; |
| |
| translateAnnouncementThenAssemble(Ref { *object }, { announcement }, WTF::move(assemble)); |
| } |
| |
| #if PLATFORM(COCOA) |
| void AXObjectCache::postLiveRegionNotification(AccessibilityObject& object, LiveRegionStatus status, const AttributedString& announcement) |
| { |
| enqueueNotificationToPost(Ref { object }, AXNotificationWithData(AXNotification::LiveRegionAnnouncement, LiveRegionAnnouncementData { announcement, status })); |
| } |
| #endif |
| |
| void AXObjectCache::checkedStateChanged(Element& element) |
| { |
| postNotification(&element, AXNotification::CheckedStateChanged); |
| } |
| |
| void AXObjectCache::autofillTypeChanged(HTMLInputElement& element) |
| { |
| postNotification(&element, AXNotification::AutofillTypeChanged); |
| } |
| |
| void AXObjectCache::handleMenuItemSelected(Element* element) |
| { |
| if (!element) |
| return; |
| |
| if (!hasAnyRole(*element, { "menuitem"_s, "menuitemradio"_s, "menuitemcheckbox"_s })) |
| return; |
| |
| if (!element->focused() && !equalLettersIgnoringASCIICase(element->attributeWithoutSynchronization(aria_selectedAttr), "true"_s)) |
| return; |
| |
| postNotification(protect(getOrCreate(*element)), protect(document()).get(), AXNotification::MenuListItemSelected); |
| } |
| |
| void AXObjectCache::handleTabPanelSelected(Element* oldElement, Element* newElement) |
| { |
| auto updateTab = [this] (AccessibilityObject* controlPanel, Element& element) { |
| if (!controlPanel) |
| return; |
| |
| auto controllers = controlPanel->controllers(); |
| for (auto& controller : controllers) |
| postNotification(dynamicDowncast<AccessibilityObject>(controller.get()), protect(element.document()).ptr(), AXNotification::SelectedStateChanged); |
| }; |
| |
| |
| RefPtr oldObject = get(oldElement); |
| RefPtr<AccessibilityObject> oldFocusedControlledPanel; |
| if (oldObject) { |
| oldFocusedControlledPanel = Accessibility::findAncestor<AccessibilityObject>(*oldObject, false, [] (auto& ancestor) { |
| return ancestor.role() == AccessibilityRole::TabPanel; |
| }); |
| |
| updateTab(oldFocusedControlledPanel.get(), *oldElement); |
| } |
| |
| RefPtr newObject = get(newElement); |
| if (!newObject) |
| return; |
| |
| RefPtr newFocusedControlledPanel = Accessibility::findAncestor<AccessibilityObject>(*newObject, false, [] (auto& ancestor) { |
| return ancestor.role() == AccessibilityRole::TabPanel; |
| }); |
| |
| if (oldFocusedControlledPanel != newFocusedControlledPanel) |
| updateTab(newFocusedControlledPanel.get(), *newElement); |
| } |
| |
| void AXObjectCache::handleRowCountChanged(AccessibilityObject* axObject, Document* document) |
| { |
| if (!axObject) |
| return; |
| |
| axObject->recomputeIsExposableIfNecessary(); |
| postNotification(axObject, document, AXNotification::RowCountChanged); |
| } |
| |
| void AXObjectCache::onPageActivityStateChange(OptionSet<ActivityState> newState) |
| { |
| m_pageActivityState = newState; |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (auto tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->setPageActivityState(newState); |
| #endif |
| } |
| |
| #if ENABLE(WRITING_TOOLS) |
| void AXObjectCache::setWritingToolsAvailable(bool isAvailable) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (auto tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->setWritingToolsAvailable(isAvailable); |
| #else |
| UNUSED_PARAM(isAvailable); |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| } |
| #endif // ENABLE(WRITING_TOOLS) |
| |
| static bool shouldDeferFocusChange(Element* element) |
| { |
| if (!element) |
| return false; |
| |
| CheckedPtr renderer = element->renderer(); |
| if (renderer && rendererNeedsDeferredUpdate(*renderer)) |
| return true; |
| |
| #if PLATFORM(IOS_FAMILY) |
| // Date/DateTimeLocal input fields present popovers in the UI process synchronously. |
| // The web process can infer that this will happen via a DOMActivateEvent, but this may be dispatched after the focused change event. |
| // Defer the focus until after BaseDateAndTimeInputType::showPicker is called and has set the appropriate state (willPresentDatePopover) on AXObjectCache. |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(element)) { |
| if (input->isDateField() || input->isDateTimeLocalField()) |
| return true; |
| } |
| #endif // PLATFORM(IOS_FAMILY) |
| |
| // We also want to defer handling focus changes for nodes that haven't yet attached their renderer. |
| if (CheckedPtr style = element->existingComputedStyle()) |
| return !renderer && element->rendererIsNeeded(*style); |
| // No existing style, so we can't easily determine whether this element will need a renderer. |
| // Resolving style is expensive and we don't want to do it now, so make this decision assuming |
| // a renderer just hasn't been attached yet, indicated by it being nullptr. |
| return !renderer; |
| } |
| |
| void AXObjectCache::onFocusChange(Element* oldElement, Element* newElement) |
| { |
| if (m_suppressedFocusChange && m_suppressedFocusChange->get() == newElement) { |
| // We deliberately don't want to surface this focus change to assistive technology. |
| // One situation where this is relevant is downstream of invoking an aria-action. |
| // Inherently, the simulated click that results from invoking the action moves |
| // focus to the action target, but the user experience for aria-actions demands |
| // that focus "stay on" (or immediately bounce back to) the originating element. |
| // We explicitly do not want assistive technologies to actually bounce back and forth |
| // as that would cause confusing announcements, so we supress the focus change. |
| // |
| // A null suppressed element matches a clearing of focus, used when the focus we're |
| // restoring had no origin (nothing was focused before the action). |
| return; |
| } |
| |
| if (m_deferredRemoteFrameFocus) { |
| if (newElement) { |
| // Focus is going to a local element, not the remote frame. |
| // Clear the pending remote frame focus. |
| m_deferredRemoteFrameFocus = std::nullopt; |
| // Fall through to process this focus change normally. |
| } else { |
| // When focus moves from a local element into a remote frame, this |
| // method will be called with a null |newElement|. We still want |
| // to process the remote-frame focus in this case, so make sure |
| // we we've captured the old-focus element (updating it if necessary) |
| // and returning before running any of the other logic in this method. |
| if (oldElement) |
| m_deferredRemoteFrameFocus->oldFocusedElement = oldElement; |
| return; |
| } |
| } |
| |
| if (shouldDeferFocusChange(newElement)) { |
| if (m_deferredFocusedNodeChange) { |
| // If we got a focus change notification but haven't committed a previously deferred focus change: |
| if (m_deferredFocusedNodeChange->first == newElement) { |
| // Cancel the focus change entirely if the new focused node will be the same as the old one (i.e. there is no effective focus change). |
| m_deferredFocusedNodeChange = std::nullopt; |
| return; |
| } |
| // Otherwise, recompute is-ignored for the node that was slated to become the AX focused element. |
| // This is important because we may have computed is-ignored for this node after it gained DOM focus, |
| // meaning it could be unignored solely because it was DOM focused. |
| recomputeIsIgnored(protect(m_deferredFocusedNodeChange->second.get())); |
| // And now we can update the new deferred focus node to be |newNode|. |
| m_deferredFocusedNodeChange->second = newElement; |
| } else |
| m_deferredFocusedNodeChange = { oldElement, newElement }; |
| |
| // Don't start the timer if a layout is pending, as the layout will trigger a cache update. |
| bool needsLayout = newElement->renderer() && newElement->renderer()->needsLayout(); |
| if (!needsLayout && !m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } else |
| handleFocusedUIElementChanged(oldElement, newElement); |
| } |
| |
| void AXObjectCache::onFrameSelectionFocusedOrActiveStateChanged(Document& document) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| // Update the WebArea's IsFocusedWebArea property to reflect the current |
| // frame->selection().isFocusedAndActive() state. |
| if (RefPtr webArea = get(document)) |
| updateIsolatedTree(*webArea, AXProperty::IsFocusedWebArea); |
| #else |
| UNUSED_PARAM(document); |
| #endif |
| } |
| |
| void AXObjectCache::onInertOrVisibilityChange(RenderElement& renderer) |
| { |
| if (renderer.style().effectiveInert() || renderer.style().usedVisibility() != Visibility::Visible) { |
| // An element becoming inert can cause all page content to become ignored, |
| // which may require overriding aria-hidden on a blocked modal to prevent |
| // an empty page. Arm the check for the next modalNode() query. |
| m_needsAriaHiddenModalOverrideCheck = true; |
| } |
| |
| #if ENABLE(INCLUDE_IGNORED_IN_CORE_AX_TREE) |
| RefPtr axObject = get(renderer); |
| if (!axObject) |
| return; |
| // Both of these change the is-ignored state of all descendants of `renderer`, so throw away |
| // the is-ignored cache. |
| stopCachingComputedObjectAttributes(); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| postNotification(*axObject, AXNotification::InertOrVisibilityChanged); |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| |
| #else // !ENABLE(INCLUDE_IGNORED_IN_CORE_AX_TREE) |
| if (CheckedPtr parent = renderer.parent()) |
| childrenChanged(*parent, &renderer); |
| #endif // ENABLE(INCLUDE_IGNORED_IN_CORE_AX_TREE) |
| } |
| |
| void AXObjectCache::onPopoverToggle(const HTMLElement& popover) |
| { |
| RefPtr axPopover = get(const_cast<HTMLElement*>(&popover)); |
| if (!axPopover) |
| return; |
| |
| // Updating the accessibility tree and sending notifications in response to a toggled |
| // popover requires accessing the popover's controllers(), which could resolve relations |
| // at a time that's not safe (i.e. if this function is called downstream of an element |
| // removal). Defer this handling to a time we know it's safe. |
| m_deferredToggledPopovers.append(axPopover.releaseNonNull()); |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| |
| void AXObjectCache::deferMenuListValueChange(Element* element) |
| { |
| if (!element) |
| return; |
| |
| m_deferredMenuListChange.add(*element); |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| |
| void AXObjectCache::deferModalChange(Element& element) |
| { |
| m_deferredModalChangedList.add(element); |
| |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| |
| void AXObjectCache::handleFocusedUIElementChanged(Element* oldElement, Element* newElement, UpdateModal updateModal) |
| { |
| #if PLATFORM(IOS_FAMILY) |
| if (willPresentDatePopover()) { |
| setWillPresentDatePopover(false); |
| if (RefPtr inputElement = dynamicDowncast<HTMLInputElement>(newElement)) { |
| if (inputElement->isDateField() || inputElement->isDateTimeLocalField()) |
| return; |
| } |
| } |
| #endif |
| |
| if (updateModal == UpdateModal::Yes) |
| updateCurrentModalNode(); |
| handleMenuItemSelected(newElement); |
| |
| // FIXME: Consider creating a new ancestor flag to only do this work when |oldNode| or |newNode| have a tab panel ancestor (the only time it is necessary) |
| handleTabPanelSelected(oldElement, newElement); |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| // Use focusedObjectForLocalFrame() instead of focusedObjectForNode() to properly handle |
| // the case where focus is in a site-isolated sub-frame (returns the AXRemoteFrame). |
| setIsolatedTreeFocusedObject(focusedObjectForLocalFrame()); |
| #if ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| // Only the focused frame's own cache runs this handler, so also refresh the isolated-tree focus |
| // of each ancestor local frame. This keeps an ancestor tree (e.g. the main frame's, which |
| // VoiceOver queries for the focused element) pointed at the AXLocalFrame leading toward the |
| // focused subframe, so AXIsolatedObject::focusedUIElementInAnyLocalFrame() can descend |
| // cross-frame to the real focused element. |
| updateAncestorFramesFocusedObject(); |
| #endif |
| #endif |
| platformHandleFocusedUIElementChanged(protect(getOrCreate(oldElement)), protect(getOrCreate(newElement))); |
| |
| // If focus landed inside an aria-hidden=true region, schedule a check for the next |
| // rendering update. This gives web developers one animation frame (via a focus event |
| // listener and requestAnimationFrame) to move focus out before we permanently override |
| // aria-hidden on the ancestor. |
| if (newElement) { |
| bool isInsideAriaHidden = false; |
| for (RefPtr ancestor = newElement; ancestor; ancestor = ancestor->parentElementInComposedTree()) { |
| if (equalLettersIgnoringASCIICase(ancestor->attributeWithDefaultARIA(aria_hiddenAttr), "true"_s)) { |
| isInsideAriaHidden = true; |
| break; |
| } |
| } |
| if (isInsideAriaHidden) { |
| m_pendingAriaHiddenFocusTarget = newElement; |
| m_needsAriaHiddenFocusCheck = true; |
| // Ensure a rendering update is scheduled so that onPostRenderingUpdate |
| // fires after animation frame callbacks have had a chance to run. |
| if (RefPtr page = this->page()) |
| page->scheduleRenderingUpdate(RenderingUpdateStep::EventRegionUpdate); |
| } else { |
| m_pendingAriaHiddenFocusTarget = nullptr; |
| m_needsAriaHiddenFocusCheck = false; |
| } |
| } |
| } |
| |
| void AXObjectCache::onPostRenderingUpdate() |
| { |
| if (!m_needsAriaHiddenFocusCheck) |
| return; |
| m_needsAriaHiddenFocusCheck = false; |
| |
| RefPtr focusTarget = m_pendingAriaHiddenFocusTarget.get(); |
| m_pendingAriaHiddenFocusTarget = nullptr; |
| if (!focusTarget) |
| return; |
| |
| // Verify focus is still on the same element. |
| RefPtr currentFocus = document()->focusedElement(); |
| if (currentFocus != focusTarget) |
| return; |
| |
| // Walk all ancestors, flagging every one that has aria-hidden=true so that the |
| // entire subtree of each becomes permanently visible. |
| for (RefPtr ancestor = focusTarget.get(); ancestor; ancestor = ancestor->parentElementInComposedTree()) { |
| auto tag = ancestor->localName(); |
| // FIXME: Should these be hasTagName() checks to also enforce the namespace? |
| if (bodyTag->hasLocalName(tag) || htmlTag->hasLocalName(tag)) |
| break; |
| |
| if (!equalLettersIgnoringASCIICase(ancestor->attributeWithDefaultARIA(aria_hiddenAttr), "true"_s)) |
| continue; |
| |
| if (RefPtr axObject = getOrCreate(*ancestor)) |
| axObject->setShouldIgnoreARIAHidden(true); |
| handleAriaHiddenChange(*ancestor); |
| } |
| } |
| |
| void AXObjectCache::selectedChildrenChanged(Node* node) |
| { |
| postNotification(node, AXNotification::SelectedChildrenChanged); |
| } |
| |
| void AXObjectCache::selectedChildrenChanged(RenderObject* renderer) |
| { |
| if (renderer) |
| selectedChildrenChanged(protect(renderer->node())); |
| } |
| |
| #if ENABLE(VIDEO) |
| void AXObjectCache::onMediaElementCurrentSrcChanged(HTMLMediaElement& element) |
| { |
| postNotification(&element, AXNotification::URLChanged); |
| } |
| #endif |
| |
| void AXObjectCache::onScrollbarFrameRectChange(const Scrollbar& scrollbar) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (!isIsolatedTreeEnabled()) |
| return; |
| |
| if (RefPtr axScrollbar = get(const_cast<Scrollbar*>(&scrollbar))) |
| std::ignore = m_geometryManager->cacheRectIfNeeded(axScrollbar->objectID(), enclosingIntRect(axScrollbar->relativeFrame())); |
| #else |
| UNUSED_PARAM(scrollbar); |
| #endif |
| } |
| |
| void AXObjectCache::onSelectedOptionChanged(Element& element) |
| { |
| if (hasCellARIARole(element)) |
| postNotification(&element, AXNotification::SelectedCellsChanged); |
| else if (is<HTMLOptionElement>(element)) |
| postNotification(&element, AXNotification::SelectedStateChanged); |
| else if (RefPtr axObject = getOrCreate(element)) { |
| if (RefPtr ancestor = Accessibility::findAncestor<AccessibilityObject>(*axObject, false, [] (const auto& object) { |
| return object.canHaveSelectedChildren(); |
| })) { |
| selectedChildrenChanged(ancestor->node()); |
| postNotification(axObject.get(), protect(element.document()).ptr(), AXNotification::SelectedStateChanged); |
| } |
| } |
| |
| handleMenuItemSelected(&element); |
| handleTabPanelSelected(nullptr, &element); |
| } |
| |
| void AXObjectCache::onSelectedOptionChanged(HTMLSelectElement& select, int optionIndex) |
| { |
| if (RefPtr axMenuList = dynamicDowncast<AccessibilityMenuList>(get(select))) |
| axMenuList->didUpdateActiveOption(optionIndex); |
| else { |
| // Base-appearance selects don't use AccessibilityMenuList (which normally handles this), |
| // so post the value change notification directly. |
| deferMenuListValueChange(&select); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| // Make sure the string value is updated in the same cycle as the expanded-state change. |
| updateIsolatedTree(get(select), AXProperty::StringValue); |
| #endif |
| } |
| |
| void AXObjectCache::onSlottedContentChange(const HTMLSlotElement& slot) |
| { |
| childrenChanged(protect(get(const_cast<HTMLSlotElement&>(slot)))); |
| } |
| |
| void AXObjectCache::onDetailsSummarySlotChange(const HTMLDetailsElement& details) |
| { |
| for (Ref summary : childrenOfType<HTMLSummaryElement>(const_cast<HTMLDetailsElement&>(details))) { |
| if (RefPtr object = get(summary)) |
| m_deferredRecomputeActiveSummaryList.add(*object); |
| } |
| } |
| |
| void AXObjectCache::onRadioGroupMembershipChanged(HTMLElement& radio) |
| { |
| if (auto* radioElement = dynamicDowncast<HTMLInputElement>(radio)) { |
| for (auto& sibling : radioElement->radioButtonGroup()) { |
| if (sibling.ptr() == &radio) |
| continue; |
| |
| if (RefPtr axObject = get(sibling.ptr())) |
| postNotification(axObject.get(), protect(sibling->document()).ptr(), AXNotification::RadioGroupMembershipChanged); |
| } |
| } |
| } |
| |
| void AXObjectCache::onRemoteFrameGainedFocus(RemoteFrame& remoteFrame) |
| { |
| RefPtr document = this->document(); |
| RefPtr page = document ? document->page() : nullptr; |
| if (!page) |
| return; |
| |
| RefPtr localFrame = page->focusController().focusedOrMainFrame(); |
| RefPtr oldFocusedElement = localFrame ? localFrame->document()->focusedElement() : nullptr; |
| |
| m_deferredRemoteFrameFocus = DeferredRemoteFrameFocus { remoteFrame, oldFocusedElement }; |
| |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| |
| void AXObjectCache::handleRemoteFrameGainedFocus(RemoteFrame& remoteFrame, Element* oldFocusedElement) |
| { |
| RefPtr document = this->document(); |
| RefPtr page = document ? document->page() : nullptr; |
| if (!page) |
| return; |
| |
| RefPtr currentFocusedFrame = page->focusController().focusedFrame(); |
| if (currentFocusedFrame.get() != &remoteFrame) { |
| // Focus moved away from the remote frame, so do nothing. |
| return; |
| } |
| |
| RefPtr scrollView = dynamicDowncast<AccessibilityScrollView>(getOrCreate(RefPtr { remoteFrame.view() }.get())); |
| RefPtr axRemoteFrame = scrollView ? scrollView->remoteFrame() : nullptr; |
| if (!axRemoteFrame) |
| return; |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| AX_ASSERT(focusedObjectForLocalFrame() == axRemoteFrame.get()); |
| setIsolatedTreeFocusedObject(axRemoteFrame.get()); |
| #endif |
| |
| // Call platform handler with the old element and the new focused AXRemoteFrame. |
| platformHandleFocusedUIElementChanged(protect(getOrCreate(oldFocusedElement)), axRemoteFrame.get()); |
| |
| // Recompute is-ignored for the old element since it lost focus. |
| recomputeIsIgnored(oldFocusedElement); |
| } |
| |
| static bool NODELETE isContentVisibilityHidden(const Style::ComputedStyle& style) |
| { |
| return style.usedContentVisibility() == ContentVisibility::Hidden; |
| } |
| |
| void AXObjectCache::onStyleChange(Element& element, OptionSet<Style::Change> change, const Style::ComputedStyle* oldStyle, const Style::ComputedStyle* newStyle) |
| { |
| if (!change || !oldStyle || !newStyle) |
| return; |
| |
| RefPtr object = get(element); |
| if (!object) |
| return; |
| |
| if (element.renderer()) { |
| // Unlike changes to `visibility`, changes to `content-visibility` do not provide accessibility |
| // children changed notifications via the render tree, so check for that here. |
| if (isContentVisibilityHidden(*oldStyle) != isContentVisibilityHidden(*newStyle)) { |
| childrenChanged(object.get()); |
| |
| #if ENABLE(INCLUDE_IGNORED_IN_CORE_AX_TREE) |
| // Object's ignored state is dependent on content-visibility:hidden, so clear the is-ignored |
| // cache and update the isolated tree. |
| stopCachingComputedObjectAttributes(); |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(*object, AXNotification::InertOrVisibilityChanged); |
| #endif |
| #endif // ENABLE(INCLUDE_IGNORED_IN_CORE_AX_TREE) |
| } |
| } else if (isVisibilityHidden(*oldStyle) != isVisibilityHidden(*newStyle)) { |
| // We only need to do this when the given element doesn't have a renderer, as if it did, we would |
| // get a children-changed event through the render tree. |
| childrenChanged(object.get()); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (oldStyle->insideLink() != newStyle->insideLink()) |
| postNotification(*object, AXNotification::VisitedStateChanged); |
| |
| if (oldStyle->speakAs() != newStyle->speakAs()) |
| postNotification(*object, AXNotification::SpeakAsChanged); |
| |
| if (oldStyle->cursorType() != newStyle->cursorType()) |
| postNotification(*object, AXNotification::CursorTypeChanged); |
| |
| if (oldStyle->pointerEvents() != newStyle->pointerEvents()) |
| postNotification(*object, AXNotification::PointerEventsChanged); |
| |
| // Certain properties (e.g. AXProperty::ShowsCursorOnHover) cannot easily |
| // be diffed here so post this general notification for those. |
| updateIsolatedTree(*object, AXNotification::StyleChanged); |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| } |
| |
| HashMap<AXID, LineRange> AXObjectCache::mostRecentlyPaintedText() |
| { |
| HashMap<AXID, LineRange> recentlyPaintedText; |
| for (auto renderTextToLineRange : m_mostRecentlyPaintedText) { |
| if (RefPtr axObject = getOrCreate(renderTextToLineRange.key)) |
| recentlyPaintedText.add(axObject->objectID(), renderTextToLineRange.value); |
| } |
| return recentlyPaintedText; |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| void AXObjectCache::onAccessibilityPaintStarted() |
| { |
| m_mostRecentlyPaintedText.clear(); |
| } |
| |
| void AXObjectCache::onAccessibilityPaintFinished() |
| { |
| RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID); |
| if (!tree) |
| return; |
| |
| for (auto iterator = m_mostRecentlyPaintedText.begin(); iterator != m_mostRecentlyPaintedText.end(); ++iterator) { |
| CheckedRef renderText = iterator->key; |
| if (auto textBox = InlineIterator::firstTextBoxInLogicalOrderFor(renderText).first) { |
| // The line index from TextBox::lineIndex is relative to the containing block, which count lines from |
| // other renderers. The LineRange struct we have built expects the start and end line indices to be |
| // relative to just this renderer, so normalize them by getting the first line index for this renderer. |
| size_t firstLineIndexForRenderer = textBox->lineIndex(); |
| AX_ASSERT(firstLineIndexForRenderer <= iterator->value.startLineIndex); |
| AX_ASSERT(firstLineIndexForRenderer <= iterator->value.endLineIndex); |
| iterator->value.startLineIndex -= firstLineIndexForRenderer; |
| iterator->value.endLineIndex -= firstLineIndexForRenderer; |
| } |
| } |
| |
| tree->markMostRecentlyPaintedTextDirty(); |
| } |
| |
| bool AXObjectCache::onFontChange(Element& element, const Style::ComputedStyle* oldStyle, const Style::ComputedStyle* newStyle) |
| { |
| if (!oldStyle || !newStyle) |
| return false; |
| |
| RefPtr object = get(element); |
| if (!object) |
| return false; |
| |
| RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID); |
| if (!tree) |
| return false; |
| |
| if (!oldStyle->fontCascadeEqual(*newStyle)) { |
| postNotification(*object, AXNotification::FontChanged); |
| return true; |
| } |
| |
| return false; |
| } |
| |
| bool AXObjectCache::onTextColorChange(Element& element, const Style::ComputedStyle* oldStyle, const Style::ComputedStyle* newStyle) |
| { |
| if (!oldStyle || !newStyle) |
| return false; |
| |
| RefPtr object = get(element); |
| if (!object) |
| return false; |
| |
| RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID); |
| if (!tree) |
| return false; |
| |
| if (oldStyle->visitedDependentColor() != newStyle->visitedDependentColor()) { |
| postNotification(*object, AXNotification::TextColorChanged); |
| return true; |
| } |
| |
| return false; |
| } |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| |
| void AXObjectCache::onStyleChange(RenderText& renderText, Style::Difference difference, const Style::ComputedStyle* oldStyle, const Style::ComputedStyle& newStyle) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (!oldStyle) |
| return; |
| |
| bool speakAsChanged = oldStyle->speakAs() != newStyle.speakAs(); |
| |
| bool diffIsEqual = difference == Style::DifferenceResult::Equal; |
| // When speak-as changes, style difference will be Style::DifferenceResult::Equal (so "equal" |
| // is not exactly accurate). So if the styles are "equal" and speak-as hasn't changed, |
| // we have nothing to do. |
| if (diffIsEqual) { |
| if (!speakAsChanged) [[likely]] |
| return; |
| } |
| |
| RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID); |
| if (!tree) |
| return; |
| |
| RefPtr object = get(renderText); |
| if (!object) |
| return; |
| |
| if (speakAsChanged) [[unlikely]] |
| postNotification(*object, AXNotification::SpeakAsChanged); |
| |
| // The following style changes will not have a Style::DifferenceResult::Equal, so we can |
| // exit early if the diff is equal. |
| if (diffIsEqual) |
| return; |
| |
| if (oldStyle->visitedDependentBackgroundColor() != newStyle.visitedDependentBackgroundColor()) |
| tree->queueNodeUpdate(object->objectID(), { AXProperty::BackgroundColor }); |
| |
| if (oldStyle->verticalAlign() != newStyle.verticalAlign()) |
| tree->queueNodeUpdate(object->objectID(), { { AXProperty::IsSuperscript, AXProperty::IsSubscript } }); |
| |
| if (oldStyle->textShadow().isNone() != newStyle.textShadow().isNone()) |
| tree->queueNodeUpdate(object->objectID(), { AXProperty::HasTextShadow }); |
| |
| auto oldDecor = oldStyle->textDecorationLineInEffect(); |
| auto newDecor = newStyle.textDecorationLineInEffect(); |
| if (oldDecor.hasUnderline() != newDecor.hasUnderline()) |
| tree->queueNodeUpdate(object->objectID(), { AXProperty::UnderlineColor }); |
| |
| if (oldDecor.hasLineThrough() != newDecor.hasLineThrough()) |
| tree->queueNodeUpdate(object->objectID(), { AXProperty::HasLinethrough }); |
| |
| if (oldStyle->textDecorationColor() != newStyle.textDecorationColor()) |
| tree->queueNodeUpdate(object->objectID(), { { AXProperty::LinethroughColor, AXProperty::UnderlineColor } }); |
| |
| if (oldStyle->pointerEvents() != newStyle.pointerEvents()) |
| postNotification(*object, AXNotification::PointerEventsChanged); |
| |
| #else |
| UNUSED_PARAM(renderText); |
| UNUSED_PARAM(difference); |
| UNUSED_PARAM(oldStyle); |
| UNUSED_PARAM(newStyle); |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| } |
| |
| void AXObjectCache::onTextSecurityChanged(HTMLInputElement& inputElement) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| postNotification(get(&inputElement), AXNotification::TextSecurityChanged); |
| #else |
| UNUSED_PARAM(inputElement); |
| #endif |
| } |
| |
| void AXObjectCache::onTitleChange(Document& document) |
| { |
| postNotification(protect(get(&document)), AXNotification::TextChanged); |
| } |
| |
| void AXObjectCache::onValidityChange(Element& element) |
| { |
| postNotification(protect(get(&element)), AXNotification::InvalidStatusChanged); |
| } |
| |
| void AXObjectCache::onTextCompositionChange(Node& node, CompositionState compositionState, bool valueChanged, const String& text, size_t position, bool handlingAcceptedCandidate) |
| { |
| #if HAVE(INLINE_PREDICTIONS) |
| RefPtr object = getOrCreate(node); |
| if (!object) |
| return; |
| |
| #if PLATFORM(IOS_FAMILY) |
| if (valueChanged) |
| object->setLastPresentedTextPrediction(node, compositionState, text, position, handlingAcceptedCandidate); |
| #endif |
| |
| if (RefPtr observableObject = object->observableObject()) |
| object = WTF::move(observableObject); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(object.get(), AXNotification::TextCompositionChanged); |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| |
| if (compositionState == CompositionState::Started) |
| postNotification(object.get(), protect(node.document()).ptr(), AXNotification::TextCompositionBegan); |
| |
| if (valueChanged) |
| postNotification(object.get(), protect(node.document()).ptr(), AXNotification::ValueChanged); |
| |
| if (compositionState == CompositionState::Ended) |
| postNotification(object.get(), protect(node.document()).ptr(), AXNotification::TextCompositionEnded); |
| #else |
| UNUSED_PARAM(node); |
| UNUSED_PARAM(compositionState); |
| UNUSED_PARAM(valueChanged); |
| #endif // HAVE(INLINE_PREDICTIONS) |
| |
| #if !PLATFORM(IOS_FAMILY) || !HAVE(INLINE_PREDICTIONS) |
| UNUSED_PARAM(text); |
| UNUSED_PARAM(position); |
| UNUSED_PARAM(handlingAcceptedCandidate); |
| #endif |
| } |
| |
| void AXObjectCache::setTextSelectionIntent(const AXTextStateChangeIntent& intent) |
| { |
| m_textSelectionIntent = intent; |
| } |
| |
| void AXObjectCache::setIsSynchronizingSelection(bool isSynchronizing) |
| { |
| m_isSynchronizingSelection = isSynchronizing; |
| } |
| |
| void AXObjectCache::postTextStateChangeNotification(Node* node, const AXTextStateChangeIntent& intent, const VisibleSelection& selection) |
| { |
| if (!node) |
| return; |
| |
| #if PLATFORM(COCOA) || USE(ATSPI) |
| stopCachingComputedObjectAttributes(); |
| |
| postTextStateChangeNotification(protect(getOrCreate(*node)), intent, selection); |
| #else |
| postNotification(node->renderer(), AXNotification::SelectedTextChanged, PostTarget::ObservableParent); |
| UNUSED_PARAM(intent); |
| UNUSED_PARAM(selection); |
| #endif |
| } |
| |
| void AXObjectCache::postTextStateChangeNotification(const Position& position, const AXTextStateChangeIntent& intent, const VisibleSelection& selection) |
| { |
| RefPtr node = position.deprecatedNode(); |
| if (!node) |
| return; |
| |
| stopCachingComputedObjectAttributes(); |
| |
| #if PLATFORM(COCOA) || USE(ATSPI) |
| RefPtr object = getOrCreate(*node); |
| if (object && object->isIgnored()) { |
| #if PLATFORM(COCOA) |
| if (position.atLastEditingPositionForNode()) { |
| if (RefPtr nextSibling = object->nextSiblingUnignored(1)) |
| object = WTF::move(nextSibling); |
| } else if (position.atFirstEditingPositionForNode()) { |
| if (RefPtr previousSibling = object->previousSiblingUnignored(1)) |
| object = WTF::move(previousSibling); |
| } |
| #elif USE(ATSPI) |
| // ATSPI doesn't expose text nodes, so we need the parent |
| // object which is the one implementing the text interface. |
| object = object->parentObjectUnignored(); |
| #endif |
| } |
| |
| postTextStateChangeNotification(object.get(), intent, selection); |
| #else |
| postTextStateChangeNotification(node.get(), intent, selection); |
| #endif |
| } |
| |
| void AXObjectCache::postTextStateChangeNotification(AccessibilityObject* object, const AXTextStateChangeIntent& intent, const VisibleSelection& selection) |
| { |
| AXTRACE(makeString("AXObjectCache::postTextStateChangeNotification 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| stopCachingComputedObjectAttributes(); |
| |
| #if PLATFORM(COCOA) || USE(ATSPI) |
| RefPtr axObject = object; |
| if (axObject) { |
| if (RefPtr observableObject = axObject->observableObject()) |
| axObject = observableObject; |
| } |
| |
| if (!axObject) |
| axObject = rootWebArea(); |
| |
| if (axObject) { |
| const AXTextStateChangeIntent& newIntent = (intent.type == AXTextStateChangeType::Unknown || (m_isSynchronizingSelection && m_textSelectionIntent.type != AXTextStateChangeType::Unknown)) ? m_textSelectionIntent : intent; |
| |
| #if PLATFORM(COCOA) |
| if (enqueuePasswordNotification(*axObject, { newIntent, { }, { }, selection })) |
| return; |
| #endif |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| // Update the isolated tree's selected text marker range before posting the |
| // notification, so that it is available when clients handle the notification. |
| onSelectedTextChanged(selection, axObject.get()); |
| #endif |
| |
| postTextSelectionChangePlatformNotification(axObject.get(), newIntent, selection); |
| } |
| |
| #else // PLATFORM(COCOA) || USE(ATSPI) |
| 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) |
| { |
| AXTRACE(makeString("AXObjectCache::postTextStateChangeNotification 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| if (!node || type == AXTextEditType::Unknown) |
| return; |
| |
| stopCachingComputedObjectAttributes(); |
| |
| RefPtr object = getOrCreate(node); |
| #if PLATFORM(COCOA) || USE(ATSPI) |
| if (object) |
| object = object->observableObject(); |
| |
| if (!object) |
| object = rootWebArea(); |
| |
| if (!object) |
| return; |
| |
| #if PLATFORM(COCOA) |
| if (enqueuePasswordNotification(*object, { { type }, { }, text, { position, position } })) |
| return; |
| #endif |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(*object, AXNotification::ValueChanged); |
| #endif |
| |
| postTextStateChangePlatformNotification(object.get(), type, text, position); |
| #else // PLATFORM(COCOA) || USE(ATSPI) |
| nodeTextChangePlatformNotification(object.get(), 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 != AXTextEditType::Delete) |
| return; |
| if (!(insertionType == AXTextEditType::Insert || insertionType == AXTextEditType::Typing || insertionType == AXTextEditType::Dictation || insertionType == AXTextEditType::Paste)) |
| return; |
| |
| stopCachingComputedObjectAttributes(); |
| |
| RefPtr object = getOrCreate(*node); |
| #if PLATFORM(COCOA) || USE(ATSPI) |
| if (object) |
| object = object->observableObject(); |
| if (!object) |
| return; |
| |
| #if PLATFORM(COCOA) |
| if (enqueuePasswordNotification(*object, { { AXTextEditType::Replace }, deletedText, insertedText, { position, position } })) |
| return; |
| #endif |
| |
| postTextReplacementPlatformNotification(object.get(), deletionType, deletedText, insertionType, insertedText, position); |
| #else // PLATFORM(COCOA) || USE(ATSPI) |
| nodeTextChangePlatformNotification(object.get(), textChangeForEditType(deletionType), position.deepEquivalent().deprecatedEditingOffset(), deletedText); |
| nodeTextChangePlatformNotification(object.get(), textChangeForEditType(insertionType), position.deepEquivalent().deprecatedEditingOffset(), insertedText); |
| #endif |
| } |
| |
| void AXObjectCache::postTextReplacementNotificationForTextControl(HTMLTextFormControlElement& textControl, const String& deletedText, const String& insertedText) |
| { |
| stopCachingComputedObjectAttributes(); |
| |
| RefPtr object = getOrCreate(textControl); |
| #if PLATFORM(COCOA) || USE(ATSPI) |
| if (object) |
| object = object->observableObject(); |
| if (!object) |
| return; |
| |
| #if PLATFORM(COCOA) |
| if (enqueuePasswordNotification(*object, { { AXTextEditType::Replace }, deletedText, insertedText, { } })) |
| return; |
| #endif |
| |
| postTextReplacementPlatformNotificationForTextControl(object.get(), deletedText, insertedText); |
| #else // PLATFORM(COCOA) || USE(ATSPI) |
| nodeTextChangePlatformNotification(object.get(), textChangeForEditType(AXTextEditType::Delete), 0, deletedText); |
| nodeTextChangePlatformNotification(object.get(), textChangeForEditType(AXTextEditType::Insert), 0, insertedText); |
| #endif |
| } |
| |
| #if PLATFORM(COCOA) |
| |
| static AXTextChangeContext secureContext(AccessibilityObject& object, AXTextChangeContext& context) |
| { |
| AX_ASSERT(isSecureFieldOrContainedBySecureField(object)); |
| |
| // FIXME: Add a better way to retrieve the maskingCharacter for secure fields. |
| String value = object.secureFieldValue(); |
| auto maskingCharacter = value.length() ? value[0] : bullet; |
| |
| const auto& secureString = [&maskingCharacter] (String& text) { |
| if (text.isEmpty()) |
| return; |
| |
| std::span<char16_t> characters; |
| text = String::createUninitialized(text.length(), characters); |
| for (unsigned i = 0; i < text.length(); ++i) |
| characters[i] = maskingCharacter; |
| }; |
| |
| secureString(context.deletedText); |
| secureString(context.insertedText); |
| return context; |
| } |
| |
| bool AXObjectCache::enqueuePasswordNotification(AccessibilityObject& object, AXTextChangeContext&& context) |
| { |
| if (!isSecureFieldOrContainedBySecureField(object)) |
| return false; |
| |
| RefPtr observableObject = object.observableObject(); |
| if (!observableObject) { |
| AX_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 unless it is queued. |
| return true; |
| } |
| |
| m_passwordNotifications.append({ *observableObject, secureContext(*observableObject, context) }); |
| if (!m_passwordNotificationTimer.isActive()) |
| m_passwordNotificationTimer.startRepeating(accessibilityPasswordValueChangeNotificationInterval); |
| |
| return true; |
| } |
| |
| void AXObjectCache::passwordNotificationTimerFired() |
| { |
| if (m_passwordNotifications.isEmpty()) { |
| m_passwordNotificationTimer.stop(); |
| return; |
| } |
| |
| auto notification = m_passwordNotifications.takeFirst(); |
| auto& context = notification.second; |
| switch (context.intent.type) { |
| case AXTextStateChangeType::Edit: |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(notification.first, AXNotification::ValueChanged); |
| #endif |
| if (context.intent.editType == AXTextEditType::Replace) { |
| postTextReplacementPlatformNotification(notification.first.ptr(), |
| AXTextEditType::Delete, context.deletedText, AXTextEditType::Insert, context.insertedText, context.selection.start()); |
| } else { |
| postTextStateChangePlatformNotification(notification.first.ptr(), |
| context.intent.editType, context.insertedText, context.selection.start()); |
| } |
| break; |
| case AXTextStateChangeType::SelectionMove: |
| case AXTextStateChangeType::SelectionExtend: |
| case AXTextStateChangeType::SelectionBoundary: |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(notification.first, AXNotification::SelectedTextChanged); |
| #endif |
| postTextSelectionChangePlatformNotification(notification.first.ptr(), |
| context.intent, context.selection); |
| break; |
| case AXTextStateChangeType::Unknown: |
| // No additional context, fallback to a ValueChanged notification. |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(notification.first, AXNotification::ValueChanged); |
| #endif |
| postPlatformNotification(notification.first, AXNotification::ValueChanged); |
| break; |
| }; |
| } |
| |
| #endif // PLATFORM(COCOA) |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| void AXObjectCache::onSelectedTextChanged(const VisiblePositionRange& selection, AccessibilityObject* object) |
| { |
| if (object) { |
| m_lastDebouncedTextRangeObject = object->objectID(); |
| if (!m_selectedTextRangeTimer.isActive()) |
| m_selectedTextRangeTimer.restart(); |
| } |
| |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) { |
| if (selection.isNull()) |
| tree->setSelectedTextMarkerRange({ }); |
| else { |
| auto startPosition = selection.start.deepEquivalent(); |
| auto endPosition = selection.end.deepEquivalent(); |
| |
| if (startPosition.isNull() || endPosition.isNull()) |
| tree->setSelectedTextMarkerRange({ }); |
| else { |
| if (RefPtr startObject = get(startPosition.anchorNode())) |
| createIsolatedObjectIfNeeded(*startObject); |
| if (RefPtr endObject = get(endPosition.anchorNode())) |
| createIsolatedObjectIfNeeded(*endObject); |
| |
| tree->setSelectedTextMarkerRange({ selection }); |
| } |
| } |
| } |
| } |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| |
| void AXObjectCache::frameLoadingEventNotification(LocalFrame* frame, AXLoadingEvent loadingEvent) |
| { |
| if (frame) { |
| // We pass the RenderView* (via contentRenderer()) rather than calling getOrCreate and passing |
| // that because some platforms don't handle all loading event types, and we don't want to call |
| // getOrCreate unnecessarily (because doing so is not always safe, and can do a fair amount of work). |
| frameLoadingEventPlatformNotification(frame->contentRenderer(), loadingEvent); |
| } |
| } |
| |
| unsigned AXObjectCache::liveRegionSnapshotBuildCount() const |
| { |
| #if PLATFORM(COCOA) |
| if (m_liveRegionManager) |
| return m_liveRegionManager->snapshotBuildCount(); |
| #endif |
| return 0; |
| } |
| |
| void AXObjectCache::resetLiveRegionSnapshotBuildCount() |
| { |
| #if PLATFORM(COCOA) |
| if (m_liveRegionManager) |
| m_liveRegionManager->resetSnapshotBuildCount(); |
| #endif |
| } |
| |
| void AXObjectCache::postLiveRegionChangeNotification(AccessibilityObject& object) |
| { |
| // Consolidate multiple live region changes to the same object within a run loop iteration. |
| // Web content (e.g. rebuilding a large calendar) can fire hundreds of text changes that each |
| // walk up to a live-region ancestor; deduplicating here and processing once when the timer fires |
| // collapses that into a single snapshot rebuild per region. On COCOA, the timer drives |
| // AXLiveRegionManager; elsewhere it posts a LiveRegionChanged notification. |
| if (m_liveRegionChangedPostTimer.isActive()) |
| m_liveRegionChangedPostTimer.stop(); |
| |
| Ref objectRef = object; |
| if (!m_changedLiveRegions.contains(objectRef)) |
| m_changedLiveRegions.add(WTF::move(objectRef)); |
| |
| m_liveRegionChangedPostTimer.startOneShot(0_s); |
| } |
| |
| void AXObjectCache::liveRegionChangedNotificationPostTimerFired() |
| { |
| m_liveRegionChangedPostTimer.stop(); |
| processChangedLiveRegions(); |
| } |
| |
| void AXObjectCache::processChangedLiveRegions() |
| { |
| if (m_changedLiveRegions.isEmpty()) |
| return; |
| |
| auto changedLiveRegions = std::exchange(m_changedLiveRegions, { }); |
| |
| #if PLATFORM(COCOA) |
| if (m_liveRegionManager) { |
| for (auto& object : changedLiveRegions) |
| m_liveRegionManager->handleLiveRegionChange(object.get()); |
| return; |
| } |
| #endif |
| |
| for (auto& object : changedLiveRegions) |
| postNotification(object.ptr(), protect(object->document()).get(), AXNotification::LiveRegionChanged); |
| } |
| |
| void AXObjectCache::onScrollbarUpdate(ScrollView& view) |
| { |
| m_deferredScrollbarUpdateChangeList.add(view); |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| |
| void AXObjectCache::handleScrollbarUpdate(ScrollView& view) |
| { |
| // We don't want to create a scroll view from this method, only update an existing one. |
| RefPtr scrollViewObject = get(&view); |
| if (!scrollViewObject) |
| return; |
| |
| stopCachingComputedObjectAttributes(); |
| scrollViewObject->updateChildrenIfNecessary(); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| // AccessibilityScrollView::updateChildrenIfNecessary() rebuilds the live children |
| // but doesn't mark the scroll view as needing a children update. |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->queueNodeUpdate(scrollViewObject->objectID(), NodeUpdateOptions::childrenUpdate()); |
| #endif |
| } |
| |
| void AXObjectCache::handleAriaExpandedChange(Element& element) |
| { |
| // An aria-expanded change can cause two notifications to be posted: |
| // RowCountChanged for the tree or table ancestor of this object, and |
| // RowExpanded/Collapsed for this object. |
| if (RefPtr object = get(element)) { |
| // Find the ancestor that supports RowCountChanged if exists. |
| RefPtr ancestor = Accessibility::findAncestor<AccessibilityObject>(*object, false, [] (auto& candidate) { |
| return candidate.supportsRowCountChange(); |
| }); |
| |
| // Post that the ancestor's row count changed. |
| if (ancestor) |
| handleRowCountChanged(ancestor.get(), protect(document()).get()); |
| |
| // Post that the specific row either collapsed or expanded. |
| auto role = object->role(); |
| if (role == AccessibilityRole::Row || role == AccessibilityRole::TreeItem) |
| postNotification(object.get(), protect(document()).get(), object->isExpanded() ? AXNotification::RowExpanded : AXNotification::RowCollapsed); |
| else |
| postNotification(object.get(), protect(document()).get(), AXNotification::ExpandedChanged); |
| } |
| } |
| |
| void AXObjectCache::handleAriaHiddenChange(Element& element) |
| { |
| if (RefPtr axObject = getOrCreate(element)) { |
| #if ENABLE(INCLUDE_IGNORED_IN_CORE_AX_TREE) |
| axObject->recomputeIsIgnoredForDescendants(/* includeSelf */ true); |
| #endif |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->queueNodeUpdate(axObject->objectID(), { AXProperty::IsARIAHidden }); |
| #endif |
| updateCachedTextOfAssociatedObjects(*axObject); |
| } |
| |
| #if !ENABLE(INCLUDE_IGNORED_IN_CORE_AX_TREE) |
| if (RefPtr parent = get(element.parentNode())) |
| childrenChanged(parent.get()); |
| #endif |
| } |
| |
| void AXObjectCache::handleActiveDescendantChange(Element& element, const AtomString& oldValue, const AtomString& newValue) |
| { |
| AXTRACE("AXObjectCache::handleActiveDescendantChange"_s); |
| |
| // Use the element's document instead of the cache's document in case we're inside a frame that's managing focus. |
| RefPtr frame = element.document().frame(); |
| if (!frame || !frame->selection().isFocusedAndActive()) |
| return; |
| |
| RefPtr object = getOrCreate(element); |
| if (!object) |
| return; |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(*object, AXNotification::ActiveDescendantChanged); |
| #endif |
| |
| // Notify active descendant changes only for the focused element. |
| if (element.document().focusedElement() != &element) |
| return; |
| |
| RefPtr activeDescendant = dynamicDowncast<AccessibilityObject>(object->activeDescendant()); |
| if (!activeDescendant) { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (object->shouldFocusActiveDescendant() |
| && !oldValue.isEmpty() && newValue.isEmpty()) { |
| // The focused object just lost its active descendant, so set the IsolatedTree focused object back to it. |
| setIsolatedTreeFocusedObject(object.get()); |
| } |
| #else |
| UNUSED_PARAM(oldValue); |
| UNUSED_PARAM(newValue); |
| #endif |
| return; |
| } |
| |
| if (object->shouldFocusActiveDescendant()) { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| setIsolatedTreeFocusedObject(activeDescendant.get()); |
| #endif |
| postPlatformNotification(*activeDescendant, AXNotification::FocusedUIElementChanged); |
| } |
| |
| // Handle active-descendant changes when the target allows for it, or the controlled object allows for it. |
| RefPtr<AccessibilityObject> target; |
| if (object->supportsActiveDescendant()) |
| target = object; |
| else { |
| // Check to see if the active descendant is a descendant of an object controlled by this object. |
| // In that case, the controlled object will be the target for the notification. |
| auto controlledObjects = object->relatedObjects(AXRelation::ControllerFor); |
| if (controlledObjects.size()) { |
| target = Accessibility::findAncestor(*activeDescendant, false, [&controlledObjects] (const auto& activeDescendantAncestor) { |
| return controlledObjects.contains(Ref { activeDescendantAncestor }); |
| }); |
| } |
| } |
| |
| if (!target) |
| return; |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (target != object) |
| updateIsolatedTree(target.get(), AXNotification::ActiveDescendantChanged); |
| #endif |
| |
| postPlatformNotification(*target, AXNotification::ActiveDescendantChanged); |
| |
| // Table cell active descendant changes should trigger selected cell changes. |
| if (target->isTable() && activeDescendant->isExposedTableCell()) |
| postPlatformNotification(*target, AXNotification::SelectedCellsChanged); |
| } |
| |
| void AXObjectCache::handleRoleChanged(Element& element, const AtomString& oldValue, const AtomString& newValue) |
| { |
| AXTRACE("AXObjectCache::handleRoleChanged"_s); |
| AXLOG(makeString("oldValue "_s, oldValue, " new value "_s, newValue)); |
| AX_ASSERT(oldValue != newValue); |
| |
| RefPtr object = get(element); |
| if (!object) |
| return; |
| |
| // The class of an AX object created for an Element depends on the role attribute of that Element. |
| // Thus when the role changes, remove the existing AX object and force a ChildrenChanged on the parent |
| // so that the object is re-created. |
| if (oldValue.isEmpty() || oldValue == "row"_s || newValue.isEmpty() || newValue == "row"_s) { |
| if (auto* parent = object->parentObject()) { |
| remove(element); |
| childrenChanged(parent); |
| return; |
| } |
| } |
| |
| object->updateRole(); |
| } |
| |
| void AXObjectCache::handleRoleChanged(AccessibilityObject& axObject, AccessibilityRole oldRole) |
| { |
| stopCachingComputedObjectAttributes(); |
| axObject.recomputeIsIgnored(); |
| |
| #if PLATFORM(MAC) |
| if (axObject.supportsLiveRegion()) |
| deferSortForNewLiveRegion(axObject); |
| else if (AXCoreObject::liveRegionStatusIsEnabled(AtomString { AXCoreObject::defaultLiveRegionStatusForRole(oldRole) })) |
| removeLiveRegion(axObject); |
| #else |
| UNUSED_PARAM(oldRole); |
| #endif // PLATFORM(MAC) |
| |
| if (axObject.needsRareData()) { |
| // A role change may have caused an object gain the need for rare data, so handle that here. |
| axObject.ensureRareData(); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| postNotification(axObject, AXNotification::RoleChanged); |
| #endif |
| } |
| |
| void AXObjectCache::handleARIARoleDescriptionChanged(Element& element) |
| { |
| RefPtr object = get(element); |
| if (!object) |
| return; |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(object.get(), AXNotification::ARIARoleDescriptionChanged); |
| #endif |
| } |
| |
| void AXObjectCache::handleInputTypeChanged(Element& element) |
| { |
| RefPtr object = get(element); |
| if (!object) |
| return; |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(object.get(), AXNotification::InputTypeChanged); |
| #endif |
| } |
| |
| |
| void AXObjectCache::deferAttributeChangeIfNeeded(Element& element, const QualifiedName& attrName, const AtomString& oldValue, const AtomString& newValue) |
| { |
| AXTRACE(makeString("AXObjectCache::deferAttributeChangeIfNeeded 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| |
| // Because the |id| attribute requires dirtying relations, and this code is called directly |
| // downstream of DOM changes, we can never eagerly process attribute changes for it. Here's |
| // an example scenario where this would cause issues: |
| // |
| // 1. VoiceOver requests focus to be set on an element. |
| // 2. A JS event handler is synchronously called, changing an id. |
| // 3. We handle it eagerly, dirtying relations. |
| // 4. The event handler also does something else that would cause us to un-dirty relations, |
| // like calling parentObject() on any object as a result of an element removal |
| // 5. We crash because we try to un-dirty relations when style / layout is dirty. |
| if (attrName == idAttr || (nodeRendererIsValid(element) && rendererNeedsDeferredUpdate(*element.renderer()))) { |
| m_deferredAttributeChange.append({ element, attrName, oldValue, newValue }); |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| AXLOG(makeString("Deferring handling of attribute "_s, attrName.localName().string(), " for element "_s, element.debugDescription())); |
| return; |
| } |
| Ref protectedElement { element }; |
| handleAttributeChange(protectedElement.ptr(), attrName, oldValue, newValue); |
| } |
| |
| void AXObjectCache::handleReferenceTargetChanged() |
| { |
| relationsNeedUpdate(true); |
| } |
| |
| void AXObjectCache::handlePageEditibilityChanged(Document& document) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| postNotification(&document, AXNotification::IsEditableWebAreaChanged); |
| #else |
| UNUSED_PARAM(document); |
| #endif |
| } |
| |
| bool AXObjectCache::shouldProcessAttributeChange(Element* element, const QualifiedName& attrName) |
| { |
| if (!element) |
| return false; |
| |
| // aria-modal ends up affecting sub-trees that are being shown/hidden so it's likely that |
| // an AT would not have accessed this node yet. |
| if (attrName == aria_modalAttr) |
| return true; |
| |
| // If an AXObject has yet to be created, then there's no need to process attribute changes. |
| // Some of these notifications are processed on the parent, so allow that to proceed as well |
| return get(*element) || get(protect(element->parentNode())); |
| } |
| |
| void AXObjectCache::handleAttributeChange(Element* element, const QualifiedName& attrName, const AtomString& oldValue, const AtomString& newValue) |
| { |
| AXTRACE(makeString("AXObjectCache::handleAttributeChange 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| AXLOG(makeString("attribute "_s, attrName.localName(), " for element "_s, element ? element->debugDescription() : "nullptr"_str)); |
| AXLOG(makeString("old value: "_s, oldValue, " new value: "_s, newValue)); |
| |
| enum class TableProperty : uint8_t { Exposed = 1 << 0, CellSlots = 1 << 1 }; |
| auto recomputeParentTableProperties = [this] (Element* element, OptionSet<TableProperty> properties) { |
| AX_ASSERT(!properties.isEmpty()); |
| |
| if (!properties.contains(TableProperty::CellSlots)) { |
| // If we're re-computing the exposed state of the table, we only need to do work for non-ARIA tables, allowing us to |
| // do a cheap dynamicDowncast check for an HTMLTablePartElement rather than calling AccessibilityNodeObject::parentTable(). |
| // (ARIA tables are inherently always exposed). |
| if (auto* tablePartElement = dynamicDowncast<HTMLTablePartElement>(element)) |
| deferRecomputeTableIsExposed(const_cast<HTMLTableElement*>(tablePartElement->findParentTable().get())); |
| } else if (RefPtr object = getOrCreate(element); object && object->isTableCell()) { |
| if (RefPtr parentTable = dynamicDowncast<AccessibilityNodeObject>(object->parentTable())) { |
| if (properties.contains(TableProperty::Exposed) && !parentTable->isAriaTable()) |
| deferRecomputeTableIsExposed(protect(parentTable->element())); |
| if (properties.contains(TableProperty::CellSlots)) |
| deferRecomputeTableCellSlots(*parentTable); |
| } |
| } |
| }; |
| |
| if (!shouldProcessAttributeChange(element, attrName)) |
| return; |
| // The remaining code in this method relies on shouldProcessAttributeChange null-checking element. |
| AX_ASSERT(element); |
| |
| if (isRelationAttribute(attrName)) |
| updateRelations(*element, attrName); |
| |
| if (attrName == hrefAttr) { |
| // An anchor's role depends on whether it is a link (Element::isLink(), i.e. whether it has an |
| // href). Recompute the role when href changes so a hrefless anchor with a click handler can |
| // become a link, and vice versa. |
| if (is<HTMLAnchorElement>(element) || is<SVGAElement>(element)) { |
| if (RefPtr object = get(*element)) |
| object->updateRole(); |
| } |
| } |
| |
| if (attrName == roleAttr) |
| handleRoleChanged(*element, oldValue, newValue); |
| else if (attrName == altAttr || attrName == titleAttr) |
| handleTextChanged(protect(getOrCreate(*element))); |
| else if (attrName == contenteditableAttr) { |
| if (RefPtr axObject = get(*element)) |
| axObject->updateRole(); |
| } else if (attrName == disabledAttr) |
| postNotification(element, AXNotification::DisabledStateChanged); |
| else if (attrName == forAttr) { |
| if (RefPtr label = dynamicDowncast<HTMLLabelElement>(element)) { |
| m_elementsWithRelationAttributes.add(*label); |
| bool updatedLabelFor = updateLabelFor(*label); |
| |
| if (updatedLabelFor) { |
| Ref treeScope = element->treeScope(); |
| if (RefPtr oldControl = treeScope->elementByIdResolvingReferenceTarget(oldValue)) |
| postNotification(oldControl.get(), AXNotification::TextChanged); |
| if (RefPtr newControl = treeScope->elementByIdResolvingReferenceTarget(newValue)) |
| postNotification(newControl.get(), AXNotification::TextChanged); |
| } |
| } |
| } else if (attrName == requiredAttr) |
| postNotification(element, AXNotification::RequiredStatusChanged); |
| else if (attrName == tabindexAttr) { |
| if (oldValue.isEmpty() || newValue.isEmpty()) { |
| #if ENABLE(INCLUDE_IGNORED_IN_CORE_AX_TREE) |
| // When ENABLE(INCLUDE_IGNORED_IN_CORE_AX_TREE), we don't need to do issue any children-changed events, |
| // which is quite expensive — just re-compute is-ignored on this single object. |
| if (RefPtr object = get(*element)) |
| object->recomputeIsIgnored(); |
| #else |
| RefPtr parent = element->parentNode(); |
| if (auto* renderer = parent ? parent->renderer() : nullptr) |
| childrenChanged(*renderer); |
| #endif // ENABLE(INCLUDE_IGNORED_IN_CORE_AX_TREE) |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| postNotification(element, AXNotification::FocusableStateChanged); |
| #endif |
| } |
| } |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| else if (attrName == draggableAttr) |
| postNotification(get(*element), AXNotification::DraggableStateChanged); |
| else if (attrName == langAttr) |
| updateIsolatedTree(get(*element), AXNotification::LanguageChanged); |
| else if (attrName == nameAttr) |
| postNotification(get(*element), AXNotification::NameChanged); |
| else if (attrName == placeholderAttr) |
| postNotification(element, AXNotification::PlaceholderChanged); |
| else if (attrName == hrefAttr || attrName == srcAttr) |
| postNotification(element, AXNotification::URLChanged); |
| else if (attrName == accesskeyAttr) |
| updateIsolatedTree(get(*element), AXNotification::AccessKeyChanged); |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| else if (attrName == idAttr) { |
| if (RefPtr axObject = get(*element)) { |
| if (std::optional ownerIDs = relatedObjectIDsFor(*axObject, AXRelation::OwnedBy, UpdateRelations::No)) { |
| // If this element is currently owned via aria-owns, notify its owner(s) that their children have |
| // changed (since they no longer own us considering our new id). |
| for (AXID ownerID : *ownerIDs) { |
| if (RefPtr owner = objectForID(ownerID)) |
| childrenChanged(owner.get()); |
| } |
| } |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (AXIsolatedTree::shouldCacheIdentifierAttribute()) |
| updateIsolatedTree(get(*element), AXNotification::IdAttributeChanged); |
| #endif |
| } |
| else if (attrName == openAttr) { |
| if (is<HTMLDialogElement>(*element)) { |
| deferModalChange(*element); |
| recomputeIsIgnored(protect(element->parentNode())); |
| } else if (is<HTMLDetailsElement>(*element)) { |
| if (RefPtr object = get(*element)) { |
| postNotification(*object, AXNotification::ExpandedChanged); |
| childrenChanged(object.get()); |
| object->recomputeIsIgnoredForDescendants(); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| for (Ref summary : descendantsOfType<HTMLSummaryElement>(*element)) |
| updateIsolatedTree(get(WTF::move(summary)), AXNotification::ExpandedChanged); |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| } |
| } else if (attrName == rowspanAttr) { |
| deferRowspanChange(get(*element)); |
| recomputeParentTableProperties(element, TableProperty::CellSlots); |
| } else if (attrName == colspanAttr) { |
| postNotification(element, AXNotification::ColumnSpanChanged); |
| recomputeParentTableProperties(element, TableProperty::CellSlots); |
| } else if (attrName == commandAttr) |
| postNotification(element, AXNotification::CommandChanged); |
| else if (attrName == commandforAttr) |
| postNotification(element, AXNotification::CommandForChanged); |
| else if (attrName == popovertargetAttr) |
| postNotification(element, AXNotification::PopoverTargetChanged); |
| else if (attrName == scopeAttr) |
| postNotification(element, AXNotification::CellScopeChanged); |
| else if (attrName == datetimeAttr) |
| postNotification(element, AXNotification::DatetimeChanged); |
| else if (attrName == abbrAttr) |
| postNotification(element, AXNotification::AbbreviationChanged); |
| else if (attrName == hiddenAttr) |
| postNotification(element, AXNotification::HiddenStateChanged); |
| else if (attrName == typeAttr) |
| handleInputTypeChanged(*element); |
| |
| if (!attrName.localName().string().startsWith("aria-"_s)) |
| return; |
| |
| if (attrName == aria_activedescendantAttr) |
| handleActiveDescendantChange(*element, oldValue, newValue); |
| else if (attrName == aria_atomicAttr) |
| postNotification(element, AXNotification::IsAtomicChanged); |
| else if (attrName == aria_busyAttr) |
| postNotification(element, AXNotification::ElementBusyChanged); |
| else if (attrName == aria_controlsAttr) |
| postNotification(element, AXNotification::ControlledObjectsChanged); |
| else if (attrName == aria_valuenowAttr || attrName == aria_valuetextAttr) |
| postNotification(element, AXNotification::ValueChanged); |
| else if (attrName == aria_labelAttr && element->elementName() == ElementName::HTML_html) { |
| // When aria-label changes on an <html> element, it's the web area who needs to re-compute its accessibility text. |
| handleTextChanged(protect(get(protect(element->document()).ptr()))); |
| } else if (attrName == aria_labelAttr || attrName == aria_labeledbyAttr || attrName == aria_labelledbyAttr) { |
| RefPtr axObject = get(*element); |
| if (!axObject) |
| return; |
| |
| if (hasAnyRole(*element, { "form"_s, "region"_s })) { |
| // https://w3c.github.io/aria/#document-handling_author-errors_roles |
| // The computed role of ARIA forms and regions is dependent on whether they have a label. |
| if (oldValue.isEmpty() || newValue.isEmpty()) |
| axObject->updateRole(); |
| } |
| handleTextChanged(axObject.get()); |
| } else if (attrName == aria_checkedAttr) |
| checkedStateChanged(*element); |
| else if (attrName == aria_colcountAttr) { |
| postNotification(element, AXNotification::ColumnCountChanged); |
| deferRecomputeTableIsExposed(dynamicDowncast<HTMLTableElement>(element)); |
| } else if (attrName == aria_colindexAttr) { |
| postNotification(element, AXNotification::ARIAColumnIndexChanged); |
| recomputeParentTableProperties(element, TableProperty::Exposed); |
| } else if (attrName == aria_colindextextAttr) { |
| postNotification(element, AXNotification::ARIAColumnIndexTextChanged); |
| } else if (attrName == aria_colspanAttr) { |
| postNotification(element, AXNotification::ColumnSpanChanged); |
| recomputeParentTableProperties(element, { TableProperty::CellSlots, TableProperty::Exposed }); |
| } else if (attrName == aria_describedbyAttr) |
| postNotification(element, AXNotification::DescribedByChanged); |
| else if (attrName == aria_descriptionAttr) |
| postNotification(element, AXNotification::ExtendedDescriptionChanged); |
| else if (attrName == aria_dropeffectAttr) |
| postNotification(element, AXNotification::DropEffectChanged); |
| else if (attrName == aria_flowtoAttr) |
| postNotification(element, AXNotification::FlowToChanged); |
| else if (attrName == aria_grabbedAttr) |
| postNotification(element, AXNotification::GrabbedStateChanged); |
| else if (attrName == aria_keyshortcutsAttr) |
| postNotification(element, AXNotification::KeyShortcutsChanged); |
| else if (attrName == aria_levelAttr) |
| postNotification(element, AXNotification::LevelChanged); |
| else if (attrName == aria_liveAttr) { |
| postNotification(element, AXNotification::LiveRegionStatusChanged); |
| |
| #if PLATFORM(MAC) |
| if (RefPtr object = getOrCreate(element)) { |
| if (object->supportsLiveRegion()) |
| deferSortForNewLiveRegion(object.releaseNonNull()); |
| else |
| removeLiveRegion(*object); |
| } |
| #endif // PLATFORM(MAC) |
| } else if (attrName == aria_placeholderAttr) |
| postNotification(element, AXNotification::PlaceholderChanged); |
| else if (attrName == aria_rowindexAttr) { |
| postNotification(element, AXNotification::ARIARowIndexChanged); |
| recomputeParentTableProperties(element, { TableProperty::CellSlots, TableProperty::Exposed }); |
| } else if (attrName == aria_rowindextextAttr) |
| postNotification(element, AXNotification::ARIARowIndexTextChanged); |
| else if (attrName == aria_valuemaxAttr) |
| postNotification(element, AXNotification::MaximumValueChanged); |
| else if (attrName == aria_valueminAttr) |
| postNotification(element, AXNotification::MinimumValueChanged); |
| else if (attrName == aria_multilineAttr) { |
| if (RefPtr axObject = get(*element)) { |
| // The role of textarea and textfield objects is dependent on whether they can span multiple lines, so recompute it here. |
| if (axObject->role() == AccessibilityRole::TextArea || axObject->role() == AccessibilityRole::TextField) |
| axObject->updateRole(); |
| } |
| } else if (attrName == aria_multiselectableAttr) |
| postNotification(element, AXNotification::MultiSelectableStateChanged); |
| else if (attrName == aria_orientationAttr) |
| postNotification(element, AXNotification::OrientationChanged); |
| else if (attrName == aria_posinsetAttr) |
| postNotification(element, AXNotification::PositionInSetChanged); |
| else if (attrName == aria_relevantAttr) |
| postNotification(element, AXNotification::LiveRegionRelevantChanged); |
| else if (attrName == aria_selectedAttr) |
| onSelectedOptionChanged(*element); |
| else if (attrName == aria_setsizeAttr) |
| postNotification(element, AXNotification::SetSizeChanged); |
| else if (attrName == aria_expandedAttr) |
| handleAriaExpandedChange(*element); |
| else if (attrName == aria_haspopupAttr) |
| postNotification(element, AXNotification::HasPopupChanged); |
| else if (attrName == aria_hiddenAttr) { |
| // If aria-hidden was removed or set to a non-true value, clear the permanent override flag. |
| if (!equalLettersIgnoringASCIICase(element->attributeWithDefaultARIA(aria_hiddenAttr), "true"_s)) { |
| if (RefPtr axObject = getOrCreate(*element)) |
| axObject->setShouldIgnoreARIAHidden(false); |
| } else { |
| // Setting aria-hidden="true" can cause all page content to become |
| // ignored, which may require overriding this aria-hidden on a blocked |
| // modal to prevent an empty page. Arm the check for the next |
| // modalNode() query. |
| m_needsAriaHiddenModalOverrideCheck = true; |
| } |
| handleAriaHiddenChange(*element); |
| |
| if (RefPtr currentModalElement = m_currentModalElement.get(); currentModalElement && currentModalElement->isDescendantOf(element)) |
| deferModalChange(*currentModalElement); |
| } else if (attrName == aria_invalidAttr) |
| postNotification(element, AXNotification::InvalidStatusChanged); |
| else if (attrName == aria_modalAttr) { |
| // aria-modal changed, so the element may have become modal or un-modal. |
| if (isModalElement(*element)) |
| m_modalElements.appendIfNotContains(element); |
| else |
| m_modalElements.removeAll(element); |
| deferModalChange(*element); |
| } else if (attrName == aria_currentAttr) |
| postNotification(element, AXNotification::CurrentStateChanged); |
| else if (attrName == aria_disabledAttr) |
| postNotification(element, AXNotification::DisabledStateChanged); |
| else if (attrName == aria_pressedAttr) |
| postNotification(element, AXNotification::PressedStateChanged); |
| else if (attrName == aria_readonlyAttr) |
| postNotification(element, AXNotification::ReadOnlyStatusChanged); |
| else if (attrName == aria_requiredAttr) |
| postNotification(element, AXNotification::RequiredStatusChanged); |
| else if (attrName == aria_roledescriptionAttr) |
| handleARIARoleDescriptionChanged(*element); |
| else if (attrName == aria_rowcountAttr) |
| handleRowCountChanged(protect(get(*element)), protect(element->document()).ptr()); |
| else if (attrName == aria_rowspanAttr) { |
| deferRowspanChange(get(*element)); |
| recomputeParentTableProperties(element, { TableProperty::CellSlots, TableProperty::Exposed }); |
| } else if (attrName == aria_sortAttr) |
| postNotification(element, AXNotification::SortDirectionChanged); |
| else if (attrName == aria_ownsAttr) { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (oldValue.isEmpty() || newValue.isEmpty()) |
| updateIsolatedTree(get(*element), AXProperty::SupportsARIAOwns); |
| #endif |
| auto updateStitchGroups = [&] (const AtomString& ariaOwnsValue) { |
| // Stitch groups are computed by the containing block-flow, so loop over the owned DOM id's |
| // to find the associated element's containing-block flow and mark it dirty. |
| SpaceSplitString ids(ariaOwnsValue, SpaceSplitString::ShouldFoldCase::No); |
| for (auto& id : ids) { |
| if (RefPtr ownsTarget = protect(element->treeScope())->elementByIdResolvingReferenceTarget(id)) { |
| CheckedPtr renderer = ownsTarget->renderer(); |
| if (CheckedPtr containingBlock = renderer ? renderer->containingBlock() : nullptr) |
| setDirtyStitchGroups(*containingBlock); |
| } |
| } |
| }; |
| |
| updateStitchGroups(oldValue); |
| updateStitchGroups(newValue); |
| } |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| else if (attrName == aria_braillelabelAttr) |
| postNotification(element, AXNotification::BrailleLabelChanged); |
| else if (attrName == aria_brailleroledescriptionAttr) |
| postNotification(element, AXNotification::BrailleRoleDescriptionChanged); |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| } |
| |
| void AXObjectCache::updateCachedTextOfAssociatedObjects(AccessibilityObject& object) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (!isIsolatedTreeEnabled()) |
| return; |
| |
| RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID); |
| if (!tree) |
| return; |
| |
| // Update the cached text of ancestors who may have gathered |object|s text in a |
| // text-under-element traversal. |
| for (RefPtr current = object; current; current = current->parentObject()) { |
| tree->queueNodeUpdate(current->objectID(), { AXProperty::AccessibilityText }); |
| |
| // Also update any object who used |current| as a label. |
| for (Ref labelledByObject : current->labelForObjects()) |
| tree->queueNodeUpdate(labelledByObject->objectID(), { AXProperty::AccessibilityText }); |
| } |
| #else |
| UNUSED_PARAM(object); |
| #endif |
| } |
| |
| static bool NODELETE hasAnyARIALabelling(Element& element) |
| { |
| return element.hasAttributeWithoutSynchronization(aria_labelAttr) |
| || element.hasAttributeWithoutSynchronization(aria_labelledbyAttr) |
| || element.hasAttributeWithoutSynchronization(aria_labeledbyAttr); |
| } |
| |
| void AXObjectCache::handleLabelChanged(AccessibilityObject* object) |
| { |
| AXTRACE("AXObjectCache::handleLabelChanged"_s); |
| |
| if (!object) |
| return; |
| |
| bool updatedLabelFor = false; |
| if (RefPtr label = dynamicDowncast<HTMLLabelElement>(object->element())) |
| updatedLabelFor = updateLabelFor(*label); |
| |
| if (!updatedLabelFor) { |
| auto labeledObjects = object->labelForObjects(); |
| for (auto& labeledObject : labeledObjects) { |
| updateLabeledBy(RefPtr { labeledObject->element() }.get()); |
| postNotification(&downcast<AccessibilityObject>(labeledObject.get()), protect(document()).get(), AXNotification::ValueChanged); |
| } |
| } |
| |
| postNotification(object, protect(document()).get(), AXNotification::LabelChanged); |
| } |
| |
| bool AXObjectCache::updateLabelFor(HTMLLabelElement& label) |
| { |
| if (RefPtr control = Accessibility::controlForLabelElement(label)) { |
| if (hasAnyARIALabelling(*control)) |
| return false; |
| } |
| |
| removeRelation(label, AXRelation::LabelFor); |
| addLabelForRelation(label); |
| return true; |
| } |
| |
| void AXObjectCache::updateLabeledBy(Element* element) |
| { |
| if (!element) |
| return; |
| |
| bool changedRelation = removeRelation(*element, AXRelation::LabeledBy); |
| changedRelation |= addRelation(*element, aria_labelledbyAttr); |
| if (changedRelation) |
| dirtyIsolatedTreeRelations(); |
| } |
| |
| void AXObjectCache::dirtyIsolatedTreeRelations() |
| { |
| AXTRACE("AXObjectCache::dirtyIsolatedTreeRelations"_s); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->markRelationsDirty(); |
| #endif |
| } |
| |
| void AXObjectCache::recomputeIsIgnored(RenderObject& renderer) |
| { |
| if (RefPtr object = get(renderer)) |
| object->recomputeIsIgnored(); |
| } |
| |
| void AXObjectCache::recomputeIsIgnored(Node* node) |
| { |
| if (RefPtr object = get(node)) |
| object->recomputeIsIgnored(); |
| } |
| |
| void AXObjectCache::startCachingComputedObjectAttributesUntilTreeMutates() |
| { |
| if (!m_computedObjectAttributeCache) |
| m_computedObjectAttributeCache = makeUnique<AXComputedObjectAttributeCache>(); |
| } |
| |
| void AXObjectCache::stopCachingComputedObjectAttributes() |
| { |
| m_computedObjectAttributeCache = nullptr; |
| } |
| |
| VisiblePosition AXObjectCache::visiblePositionForTextMarkerData(const TextMarkerData& textMarkerData) |
| { |
| RefPtr node = nodeForID(textMarkerData.axObjectID()); |
| if (!node) |
| return { }; |
| |
| if (node->isPseudoElement()) |
| return { }; |
| |
| // Only the offset-in-anchor constructor takes an offset. A marker can be anchored before or |
| // after its node instead — the caret on the empty final line of a text control is anchored |
| // before the placeholder <br>, for instance — and those anchor types have their own constructor, |
| // which derives the offset from the node. |
| auto position = textMarkerData.anchorType == Position::PositionIsOffsetInAnchor |
| ? Position { node.get(), textMarkerData.offset, textMarkerData.anchorType } |
| : Position { node.get(), textMarkerData.anchorType }; |
| auto visiblePosition = VisiblePosition(position, textMarkerData.affinity); |
| auto deepPosition = visiblePosition.deepEquivalent(); |
| if (deepPosition.isNull()) |
| return { }; |
| |
| CheckedPtr renderer = deepPosition.deprecatedNode()->renderer(); |
| if (!renderer) |
| return { }; |
| |
| CheckedPtr cache = protect(renderer->document())->axObjectCache(); |
| // Return an empty position if the object associated with the text marker has been destroyed. |
| if (!cache || !cache->objectForID(*textMarkerData.axObjectID())) |
| return { }; |
| |
| return visiblePosition; |
| } |
| |
| CharacterOffset AXObjectCache::characterOffsetForTextMarkerData(const TextMarkerData& textMarkerData) |
| { |
| if (textMarkerData.isRedacted) |
| return { }; |
| |
| RefPtr node = nodeForID(textMarkerData.axObjectID()); |
| if (!node) |
| return { }; |
| |
| CharacterOffset result(node.get(), textMarkerData.characterStart, 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 == Affinity::Upstream) |
| return previousCharacterOffset(result, false); |
| return result; |
| } |
| |
| CharacterOffset AXObjectCache::traverseToOffsetInRange(const SimpleRange& range, int offset, TraverseOption option, bool stayWithinRange) |
| { |
| bool toNodeEnd = option & TraverseOptionToNodeEnd; |
| bool validateOffset = option & TraverseOptionValidateOffset; |
| bool doNotEnterTextControls = option & TraverseOptionDoNotEnterTextControls; |
| |
| int offsetInCharacter = 0; |
| int cumulativeOffset = 0; |
| int remaining = 0; |
| int lastLength = 0; |
| RefPtr<Node> currentNode; |
| bool finished = false; |
| int lastStartOffset = 0; |
| |
| TextIteratorBehaviors behaviors; |
| if (!doNotEnterTextControls) |
| behaviors.add(TextIteratorBehavior::EntersTextControls); |
| TextIterator iterator(range, behaviors); |
| |
| // Enable the cache here for isIgnored calls in replacedNodeNeedsCharacter. |
| AXAttributeCacheScope enableCache(this); |
| |
| // 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.start.container.get(); |
| lastStartOffset = range.start.offset; |
| 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 (RefPtr preNode = previousNode(currentNode.get())) |
| return traverseToOffsetInRange(rangeForNodeContents(*preNode), offset, option); |
| return CharacterOffset(); |
| } |
| } |
| } |
| |
| // Sometimes text contents in a node are split into several iterations, so that iterator.range().startOffset() |
| // might not be the total character count. Here we use a previousNode object to keep track of that. |
| RefPtr<Node> previousNode; |
| for (; !iterator.atEnd(); iterator.advance()) { |
| int currentLength = iterator.text().length(); |
| bool hasReplacedNodeOrBR = false; |
| |
| currentNode = iterator.range().start.container; |
| |
| // When currentLength == 0, we check if there's any replaced node. |
| // If not, we skip the node with no length. |
| if (!currentLength) { |
| RefPtr childNode = iterator.node(); |
| if (AccessibilityObject::replacedNodeNeedsCharacter(*childNode)) { |
| cumulativeOffset++; |
| currentLength++; |
| currentNode = childNode; |
| hasReplacedNodeOrBR = true; |
| } else |
| continue; |
| } else { |
| // Ignore space, new line, tag node. |
| if (currentLength == 1) { |
| if (isASCIIWhitespace(iterator.text()[0])) { |
| // If the node has BR tag, we want to set the currentNode to it. |
| RefPtr childNode = iterator.node(); |
| if (childNode && childNode->renderer() && childNode->renderer()->isBR()) { |
| currentNode = childNode; |
| hasReplacedNodeOrBR = true; |
| } else if (RefPtr shadowHost = currentNode->shadowHost()) { |
| // Since we are entering text controls, we should set the currentNode |
| // to be the shadow host when there's no content. |
| if (elementIsTextControl(*shadowHost) && currentNode->isShadowRoot()) { |
| currentNode = shadowHost; |
| continue; |
| } |
| } else if (previousNode && previousNode->isTextNode() && previousNode->isDescendantOf(currentNode.get()) && elementName(*currentNode) == ElementName::HTML_p) { |
| // TextIterator is emitting an extra newline after the <p> element. We should |
| // ignore that since the extra text node is not in the DOM tree. |
| currentNode = previousNode; |
| 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().end.offset - lastLength; |
| } else { |
| lastLength = currentLength; |
| lastStartOffset = hasReplacedNodeOrBR ? 0 : iterator.range().start.offset; |
| } |
| |
| // 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.get() == range.end.container.ptr() && static_cast<int>(range.end.offset) < lastStartOffset + offsetInCharacter) |
| offsetInCharacter = range.end.offset - lastStartOffset; |
| |
| return CharacterOffset(currentNode.get(), lastStartOffset, offsetInCharacter, remaining); |
| } |
| |
| unsigned AXObjectCache::lengthForRange(const SimpleRange& range) |
| { |
| unsigned 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 { |
| if (AccessibilityObject::replacedNodeNeedsCharacter(protect(*it.node()))) |
| ++length; |
| } |
| } |
| return length; |
| } |
| |
| SimpleRange AXObjectCache::rangeForNodeContents(Node& node) |
| { |
| if (AccessibilityObject::replacedNodeNeedsCharacter(node)) { |
| // For replaced nodes without children, the node itself is included in the range. |
| if (auto range = makeRangeSelectingNode(node)) |
| return *range; |
| } |
| return makeRangeSelectingNodeContents(node); |
| } |
| |
| std::optional<SimpleRange> AXObjectCache::rangeMatchesTextNearRange(const SimpleRange& originalRange, const String& matchText) |
| { |
| // Create a large enough range to find the text within it that's being searched for. |
| unsigned textLength = matchText.length(); |
| auto startPosition = VisiblePosition(makeContainerOffsetPosition(originalRange.start)); |
| for (unsigned k = 0; k < textLength; k++) { |
| auto testPosition = startPosition.previous(); |
| if (testPosition.isNull()) |
| break; |
| startPosition = testPosition; |
| } |
| |
| auto endPosition = VisiblePosition(makeContainerOffsetPosition(originalRange.end)); |
| for (unsigned k = 0; k < textLength; k++) { |
| auto testPosition = endPosition.next(); |
| if (testPosition.isNull()) |
| break; |
| endPosition = testPosition; |
| } |
| |
| auto searchRange = makeSimpleRange(startPosition, endPosition); |
| if (!searchRange || searchRange->collapsed()) |
| return std::nullopt; |
| |
| auto targetOffset = characterCount({ searchRange->start, originalRange.start }, TextIteratorBehavior::EmitsCharactersBetweenAllVisiblePositions); |
| return findClosestPlainText(*searchRange, matchText, { }, targetOffset); |
| } |
| |
| static bool isReplacedNodeOrBR(Node& node) |
| { |
| return AccessibilityObject::replacedNodeNeedsCharacter(node) || WebCore::elementName(node) == ElementName::HTML_br; |
| } |
| |
| static bool characterOffsetsInOrder(const CharacterOffset& characterOffset1, const CharacterOffset& characterOffset2) |
| { |
| // FIXME: Should just be able to call treeOrder without accessibility-specific logic. |
| // FIXME: Not clear why CharacterOffset needs to exist at all; we have both Position and BoundaryPoint to choose from. |
| // FIXME: This function can return incorrect results for positions spanning across nested |
| // table boundaries (e.g. a text node inside a cell vs. an ancestor tbody position), and |
| // potentially other scenarios, producing reversed ranges that in turn cause incorrect behavior. |
| |
| if (characterOffset1.isNull() || characterOffset2.isNull()) |
| return false; |
| |
| if (characterOffset1.node == characterOffset2.node) |
| return characterOffset1.offset <= characterOffset2.offset; |
| |
| RefPtr node1 = characterOffset1.node; |
| RefPtr node2 = characterOffset2.node; |
| if (!node1->isCharacterDataNode() && !isReplacedNodeOrBR(*node1) && node1->hasChildNodes()) |
| node1 = node1->traverseToChildAt(characterOffset1.offset); |
| if (!node2->isCharacterDataNode() && !isReplacedNodeOrBR(*node2) && node2->hasChildNodes()) |
| node2 = node2->traverseToChildAt(characterOffset2.offset); |
| if (!node1 || !node2) |
| return false; |
| |
| auto range1 = AXObjectCache::rangeForNodeContents(*node1); |
| auto range2 = AXObjectCache::rangeForNodeContents(*node2); |
| return is_lteq(treeOrder<ComposedTree>(range1.start, range2.start)); |
| } |
| |
| static Node* resetNodeAndOffsetForReplacedNode(Node& replacedNode, int& offset, int characterCount) |
| { |
| // Use this function to include the replaced node itself in the range we are creating. |
| auto nodeRange = AXObjectCache::rangeForNodeContents(replacedNode); |
| bool isInNode = static_cast<unsigned>(characterCount) <= WebCore::characterCount(nodeRange); |
| offset = replacedNode.computeNodeIndex() + (isInNode ? 0 : 1); |
| return replacedNode.parentNode(); |
| } |
| |
| static std::optional<BoundaryPoint> boundaryPoint(const CharacterOffset& characterOffset) |
| { |
| if (characterOffset.isNull()) |
| return std::nullopt; |
| |
| int offset = characterOffset.startIndex + characterOffset.offset; |
| // Guaranteed to be non-null by checking CharacterOffset::isNull. |
| RefPtr node = characterOffset.node; |
| if (isReplacedNodeOrBR(*node)) |
| node = resetNodeAndOffsetForReplacedNode(*node, offset, characterOffset.offset); |
| |
| if (!node) |
| return std::nullopt; |
| return { { *node, static_cast<unsigned>(offset) } }; |
| } |
| |
| static bool setRangeStartOrEndWithCharacterOffset(SimpleRange& range, const CharacterOffset& characterOffset, bool isStart) |
| { |
| auto point = boundaryPoint(characterOffset); |
| if (!point) |
| return false; |
| if (isStart) |
| range.start = *point; |
| else |
| range.end = *point; |
| return true; |
| } |
| |
| std::optional<SimpleRange> AXObjectCache::rangeForUnorderedCharacterOffsets(const CharacterOffset& characterOffset1, const CharacterOffset& characterOffset2) |
| { |
| bool alreadyInOrder = characterOffsetsInOrder(characterOffset1, characterOffset2); |
| auto start = boundaryPoint(alreadyInOrder ? characterOffset1 : characterOffset2); |
| auto end = boundaryPoint(alreadyInOrder ? characterOffset2 : characterOffset1); |
| if (!start || !end) |
| return std::nullopt; |
| return { { *start, *end } }; |
| } |
| |
| TextMarkerData AXObjectCache::textMarkerDataForCharacterOffset(const CharacterOffset& characterOffset, TextMarkerOrigin origin) |
| { |
| if (characterOffset.isNull()) |
| return { }; |
| |
| if (RefPtr input = dynamicDowncast<HTMLInputElement>(characterOffset.node.get()); input && input->isSecureField()) |
| return { *this, { }, /* isRedacted */ true, origin }; |
| |
| return { *this, characterOffset, /* isRedacted */ false, origin }; |
| } |
| |
| CharacterOffset AXObjectCache::startOrEndCharacterOffsetForRange(const SimpleRange& range, bool isStart, bool enterTextControls) |
| { |
| // When getting the end CharacterOffset at node boundary, we don't want to collapse to the previous node. |
| if (!isStart && !range.end.offset) |
| return characterOffsetForNodeAndOffset(range.end.container, 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; |
| |
| Ref endNode = range.end.container; |
| if (endNode->isCharacterDataNode() && !isStart) |
| return traverseToOffsetInRange(rangeForNodeContents(endNode.get()), range.end.offset, TraverseOptionValidateOffset); |
| |
| auto copyRange = range; |
| // Change the start of the range, so the character offset starts from node beginning. |
| int offset = 0; |
| Ref node = copyRange.start.container; |
| if (node->isCharacterDataNode()) { |
| auto nodeStartOffset = traverseToOffsetInRange(rangeForNodeContents(node.get()), range.start.offset, TraverseOptionValidateOffset); |
| if (isStart) |
| return nodeStartOffset; |
| copyRange.start.offset = 0; |
| offset += nodeStartOffset.offset; |
| } |
| |
| auto options = isStart ? TraverseOptionDefault : TraverseOptionToNodeEnd; |
| if (!enterTextControls) |
| options = static_cast<TraverseOption>(options | TraverseOptionDoNotEnterTextControls); |
| return traverseToOffsetInRange(copyRange, offset, options, stayWithinRange); |
| } |
| |
| TextMarkerData AXObjectCache::startOrEndTextMarkerDataForRange(const SimpleRange& range, bool isStart) |
| { |
| auto characterOffset = startOrEndCharacterOffsetForRange(range, isStart); |
| if (characterOffset.isNull()) |
| return { }; |
| return textMarkerDataForCharacterOffset(characterOffset); |
| } |
| |
| CharacterOffset AXObjectCache::characterOffsetForNodeAndOffset(Node& node, int offset, TraverseOption option) |
| { |
| RefPtr domNode = node; |
| 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.get()); |
| if (domNode) |
| charOffset = characterOffsetForNodeAndOffset(*domNode, 0, TraverseOptionToNodeEnd); |
| else |
| return CharacterOffset(); |
| if (charOffset.offset == offset) |
| break; |
| } |
| if (offset > 0) |
| charOffset = characterOffsetForNodeAndOffset(Ref { *charOffset.node }, charOffset.offset - offset, TraverseOptionIncludeStart); |
| return charOffset; |
| } |
| |
| auto 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.get()); |
| if (!domNode) |
| return CharacterOffset(); |
| range = rangeForNodeContents(*domNode); |
| characterOffset = traverseToOffsetInRange(range, characterOffset.remaining(), option); |
| } |
| |
| return 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; |
| } |
| |
| TextMarkerData AXObjectCache::textMarkerDataForNextCharacterOffset(const CharacterOffset& characterOffset) |
| { |
| if (characterOffset.isNull()) |
| return { }; |
| |
| TextMarkerData data; |
| auto next = characterOffset; |
| auto previous = characterOffset; |
| bool shouldContinue; |
| do { |
| shouldContinue = false; |
| next = nextCharacterOffset(next, false); |
| if (next.isNull()) |
| return { }; |
| |
| if (shouldSkipBoundary(previous, next)) |
| next = nextCharacterOffset(next, false); |
| if (next.isNull() || next.isEqual(previous)) |
| return { }; |
| |
| data = textMarkerDataForCharacterOffset(next); |
| |
| // We should skip next CharacterOffset if it's visually the same. |
| auto range = rangeForUnorderedCharacterOffsets(previous, next); |
| if (!range || !lengthForRange(*range)) |
| shouldContinue = true; |
| previous = next; |
| } while (data.isRedacted || shouldContinue); |
| return data; |
| } |
| |
| AXTextMarker AXObjectCache::nextTextMarker(const AXTextMarker& marker) |
| { |
| AX_ASSERT(m_id == marker.treeID()); |
| return textMarkerDataForNextCharacterOffset(marker); |
| } |
| |
| TextMarkerData AXObjectCache::textMarkerDataForPreviousCharacterOffset(const CharacterOffset& characterOffset) |
| { |
| if (characterOffset.isNull()) |
| return { }; |
| |
| TextMarkerData data; |
| auto previous = characterOffset; |
| auto next = characterOffset; |
| bool shouldContinue; |
| do { |
| shouldContinue = false; |
| previous = previousCharacterOffset(previous, false); |
| if (previous.isNull() || previous.isEqual(next)) |
| return { }; |
| |
| data = textMarkerDataForCharacterOffset(previous); |
| |
| // We should skip previous CharacterOffset if it's visually the same. |
| auto range = rangeForUnorderedCharacterOffsets(previous, next); |
| if (!range || !lengthForRange(*range)) |
| shouldContinue = true; |
| next = previous; |
| } while (data.isRedacted || shouldContinue); |
| return data; |
| } |
| |
| AXTextMarker AXObjectCache::previousTextMarker(const AXTextMarker& marker) |
| { |
| AX_ASSERT(m_id == marker.treeID()); |
| return textMarkerDataForPreviousCharacterOffset(marker); |
| } |
| |
| 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, AllowUserSelectNone allowUserSelectNone) |
| { |
| if (characterOffset.isNull()) |
| return VisiblePosition(); |
| |
| // 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); |
| if (!range) |
| return { }; |
| return { makeContainerOffsetPosition(range->start), VisiblePosition::defaultAffinity, allowUserSelectNone }; |
| } |
| |
| CharacterOffset AXObjectCache::characterOffsetFromVisiblePosition(const VisiblePosition& targetVisiblePosition) |
| { |
| if (targetVisiblePosition.isNull()) |
| return { }; |
| |
| Position targetDeepPosition = targetVisiblePosition.deepEquivalent(); |
| // Dereferencing deprecatedNode is safe because VisiblePosition::isNull returns true if the deepEquivalent position has a null node. |
| Ref targetNode = *targetDeepPosition.deprecatedNode(); |
| if (targetNode->isCharacterDataNode()) |
| return traverseToOffsetInRange(rangeForNodeContents(targetNode.get()), targetDeepPosition.deprecatedEditingOffset(), TraverseOptionValidateOffset); |
| |
| RefPtr object = getOrCreate(targetNode.get()); |
| if (!object) |
| return { }; |
| |
| // Use nextVisiblePosition to calculate how many characters we need to traverse to the current position. |
| auto visiblePosition = object->visiblePositionRange().start; |
| int characterOffset = 0; |
| auto currentPosition = visiblePosition.deepEquivalent(); |
| while (!currentPosition.isNull() && !targetDeepPosition.equals(currentPosition)) { |
| auto previousPosition = currentPosition; |
| // Note that we explicitly _do not_ want currentPosition to be derived from visiblePositon.deepEquivalent(), |
| // as creating a VisiblePosition with a Position calls |canonicalPosition| on said Position, which critically |
| // can return a previous position, resulting in us looping infinitely. Iterating solely through |
| // |nextVisuallyDistinctCandidate|s should guarantee forward progress. |
| currentPosition = nextVisuallyDistinctCandidate(currentPosition, SkipDisplayContents::No); |
| if (currentPosition == previousPosition) { |
| // |nextVisuallyDistinctCandidate|s should guarantee forward progress, so this should be unreachable. |
| AX_ASSERT_NOT_REACHED(); |
| break; |
| } |
| visiblePosition = VisiblePosition(currentPosition, visiblePosition.affinity()); |
| |
| 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 if (currentPosition.deprecatedNode()->isCharacterDataNode()) { |
| // Sometimes VisiblePosition will move multiple characters, like emoji. |
| 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(targetNode.get()), characterOffset); |
| if (result.remainingOffset > 0 && !result.isNull() && isRendererReplacedElement(result.node->renderer())) |
| result.offset += result.remainingOffset; |
| return result; |
| } |
| |
| AccessibilityObject* AXObjectCache::objectForTextMarkerData(const TextMarkerData& textMarkerData) |
| { |
| if (textMarkerData.isRedacted) |
| return nullptr; |
| |
| RefPtr object = m_objects.get(*textMarkerData.axObjectID()); |
| if (!object) |
| return nullptr; |
| |
| Node* node = object->node(); |
| if (!node) |
| return nullptr; |
| AX_ASSERT(object.get() == getOrCreate(*node)); |
| return getOrCreate(*node); |
| } |
| |
| std::optional<TextMarkerData> AXObjectCache::textMarkerDataForVisiblePosition(const VisiblePosition& visiblePosition, TextMarkerOrigin origin) |
| { |
| if (visiblePosition.isNull()) |
| return std::nullopt; |
| |
| Position position = visiblePosition.deepEquivalent(); |
| RefPtr node = position.anchorNode(); |
| AX_ASSERT(node); |
| if (!node) |
| return std::nullopt; |
| |
| auto isSecureField = [&node] () { |
| auto* input = dynamicDowncast<HTMLInputElement>(node.get()); |
| return input && input->isSecureField(); |
| }; |
| |
| if (isSecureField()) |
| return std::nullopt; |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (shouldCreateAXThreadCompatibleMarkers()) { |
| // We need to convert the DOM offset (which is offset into pre-whitespace-collapse text) into an offset into |
| // the rendered, post-whitespace-collapse text. |
| unsigned domOffset = position.deprecatedEditingOffset(); |
| |
| auto createFromRendererAndOffset = [&] (RenderObject& renderer, unsigned offset) -> std::optional<TextMarkerData> { |
| CheckedPtr cache = renderer.document().axObjectCache(); |
| RefPtr object = cache ? cache->getOrCreate(renderer) : nullptr; |
| if (!object) |
| return std::nullopt; |
| |
| // We hardcode isRedacted to false below because we early-return for secure fields above. |
| // Assert here in case the check gets moved or changed. |
| AX_ASSERT(!isSecureField()); |
| return std::optional(TextMarkerData { |
| cache->treeID(), |
| object->objectID(), |
| offset, |
| Position::PositionIsOffsetInAnchor, |
| visiblePosition.affinity(), |
| 0, |
| offset, |
| /* isRedacted */ false, |
| origin |
| }); |
| }; |
| |
| if (isRendererReplacedElement(node->renderer()) || is<RenderLineBreak>(node->renderer())) |
| return createFromRendererAndOffset(*node->renderer(), domOffset); |
| |
| CheckedPtr<const RenderText> renderText = nullptr; |
| |
| auto boxAndOffset = visiblePosition.inlineBoxAndOffset(); |
| if (boxAndOffset.box) { |
| renderText = dynamicDowncast<RenderText>(boxAndOffset.box->renderer()); |
| domOffset = boxAndOffset.offset; |
| } |
| |
| if (!renderText) { |
| renderText = dynamicDowncast<RenderText>(node ? node->renderer() : nullptr); |
| if (!renderText) |
| return std::nullopt; |
| } |
| |
| auto [textBox, orderCache] = InlineIterator::firstTextBoxInLogicalOrderFor(*renderText); |
| if (!textBox) |
| return std::nullopt; |
| |
| unsigned differenceBetweenDomAndRenderedOffsets = textBox->minimumCaretOffset(); |
| unsigned previousEndDomOffset = textBox->maximumCaretOffset(); |
| size_t previousLineIndex = textBox->lineIndex(); |
| |
| while (domOffset > textBox->maximumCaretOffset()) { |
| textBox = InlineIterator::nextTextBoxInLogicalOrder(textBox, orderCache); |
| size_t newLineIndex = textBox->lineIndex(); |
| unsigned differenceToPrevious = textBox->minimumCaretOffset() - previousEndDomOffset; |
| // Just like when building AXTextRuns, we need to consider trimmed spaces between lines. So, if we find |
| // a gap between runs, subtract one from that gap to account for a trimmed space. |
| unsigned trimmedCharacterAdjustment = newLineIndex != previousLineIndex && differenceToPrevious ? 1 : 0; |
| differenceBetweenDomAndRenderedOffsets += differenceToPrevious - trimmedCharacterAdjustment; |
| previousEndDomOffset = textBox->maximumCaretOffset(); |
| previousLineIndex = newLineIndex; |
| } |
| AX_ASSERT(domOffset >= differenceBetweenDomAndRenderedOffsets); |
| unsigned renderedOffset = domOffset - differenceBetweenDomAndRenderedOffsets; |
| return createFromRendererAndOffset(const_cast<RenderText&>(*renderText), renderedOffset); |
| } |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| |
| // 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. |
| auto characterOffset = characterOffsetFromVisiblePosition(visiblePosition); |
| if (position.anchorType() == Position::PositionIsAfterAnchor || position.anchorType() == Position::PositionIsAfterChildren) |
| return textMarkerDataForCharacterOffset(characterOffset); |
| |
| CheckedPtr cache = protect(node->document())->axObjectCache(); |
| if (!cache) |
| return std::nullopt; |
| return { { *cache, visiblePosition, |
| characterOffset.startIndex, characterOffset.offset, /* isRedacted */ false, origin } }; |
| } |
| |
| CharacterOffset AXObjectCache::nextCharacterOffset(const CharacterOffset& characterOffset, bool ignoreNextNodeStart) |
| { |
| if (characterOffset.isNull()) |
| return CharacterOffset(); |
| |
| RefPtr node = characterOffset.node; |
| // We don't always move one 'character' at a time since there might be composed characters. |
| unsigned nextOffset = Position::uncheckedNextOffset(node.get(), characterOffset.offset); |
| CharacterOffset next = characterOffsetForNodeAndOffset(*node, nextOffset); |
| |
| // To be consistent with VisiblePosition, we should consider the case that current node end to next node start counts 1 offset. |
| RefPtr nextNode = next.node; |
| if (!ignoreNextNodeStart && !next.isNull() && !isReplacedNodeOrBR(*nextNode) && nextNode != node) { |
| if (auto range = rangeForUnorderedCharacterOffsets(characterOffset, next)) { |
| auto length = characterCount(*range); |
| if (length > nextOffset - characterOffset.offset) |
| next = characterOffsetForNodeAndOffset(*nextNode, 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(Ref { *characterOffset.node }, 0); |
| |
| // We don't always move one 'character' a time since there might be composed characters. |
| RefPtr characterOffsetNode = characterOffset.node; |
| int previousOffset = Position::uncheckedPreviousOffset(characterOffsetNode.get(), characterOffset.offset); |
| return characterOffsetForNodeAndOffset(*characterOffsetNode, previousOffset, TraverseOptionIncludeStart); |
| } |
| |
| CharacterOffset AXObjectCache::startCharacterOffsetOfWord(const CharacterOffset& characterOffset, WordSide side) |
| { |
| if (characterOffset.isNull()) |
| return CharacterOffset(); |
| |
| CharacterOffset c = characterOffset; |
| if (side == WordSide::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, WordSide side) |
| { |
| if (characterOffset.isNull()) |
| return CharacterOffset(); |
| |
| CharacterOffset c = characterOffset; |
| if (side == WordSide::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, WordSide::RightWordIfOnBoundary); |
| } |
| |
| CharacterOffset AXObjectCache::nextWordEndCharacterOffset(const CharacterOffset& characterOffset) |
| { |
| if (characterOffset.isNull()) |
| return CharacterOffset(); |
| |
| CharacterOffset nextOffset = nextCharacterOffset(characterOffset); |
| if (nextOffset.isNull()) |
| return CharacterOffset(); |
| |
| return endCharacterOffsetOfWord(nextOffset, WordSide::LeftWordIfOnBoundary); |
| } |
| |
| std::optional<SimpleRange> AXObjectCache::leftWordRange(const CharacterOffset& characterOffset) |
| { |
| CharacterOffset start = startCharacterOffsetOfWord(characterOffset, WordSide::LeftWordIfOnBoundary); |
| CharacterOffset end = endCharacterOffsetOfWord(start); |
| return rangeForUnorderedCharacterOffsets(start, end); |
| } |
| |
| std::optional<SimpleRange> AXObjectCache::rightWordRange(const CharacterOffset& characterOffset) |
| { |
| CharacterOffset start = startCharacterOffsetOfWord(characterOffset, WordSide::RightWordIfOnBoundary); |
| CharacterOffset end = endCharacterOffsetOfWord(start); |
| return rangeForUnorderedCharacterOffsets(start, end); |
| } |
| |
| static char32_t characterForCharacterOffset(const CharacterOffset& characterOffset) |
| { |
| if (characterOffset.isNull() || !characterOffset.node->isTextNode()) |
| return 0; |
| |
| char32_t ch = 0; |
| unsigned offset = characterOffset.startIndex + characterOffset.offset; |
| if (offset < protect(characterOffset.node)->textContent().length()) { |
| // FIXME: Remove IGNORE_CLANG_WARNINGS macros once one of <rdar://problem/58615489&58615391> is fixed. |
| IGNORE_CLANG_WARNINGS_BEGIN("conditional-uninitialized") |
| U16_NEXT(protect(characterOffset.node)->textContent(), offset, protect(characterOffset.node)->textContent().length(), ch); |
| IGNORE_CLANG_WARNINGS_END |
| } |
| return ch; |
| } |
| |
| char32_t AXObjectCache::characterAfter(const CharacterOffset& characterOffset) |
| { |
| return characterForCharacterOffset(nextCharacterOffset(characterOffset)); |
| } |
| |
| char32_t AXObjectCache::characterBefore(const CharacterOffset& characterOffset) |
| { |
| return characterForCharacterOffset(characterOffset); |
| } |
| |
| static bool NODELETE characterOffsetNodeIsBR(const CharacterOffset& characterOffset) |
| { |
| if (characterOffset.isNull()) |
| return false; |
| |
| return WebCore::elementName(*characterOffset.node) == ElementName::HTML_br; |
| } |
| |
| static Node* parentEditingBoundary(Node* node) |
| { |
| if (!node) |
| return nullptr; |
| |
| RefPtr documentElement = node->document().documentElement(); |
| if (!documentElement) |
| return nullptr; |
| |
| RefPtr boundary = node; |
| while (boundary != documentElement && boundary->nonShadowBoundaryParentNode() && node->hasEditableStyle() == protect(boundary->parentNode())->hasEditableStyle()) |
| boundary = boundary->nonShadowBoundaryParentNode(); |
| |
| return boundary.unsafeGet(); |
| } |
| |
| CharacterOffset AXObjectCache::nextBoundary(const CharacterOffset& characterOffset, BoundarySearchFunction searchFunction) |
| { |
| if (characterOffset.isNull()) |
| return { }; |
| |
| RefPtr boundary = parentEditingBoundary(RefPtr { characterOffset.node }.get()); |
| if (!boundary) |
| return { }; |
| |
| auto searchRange = rangeForNodeContents(*boundary); |
| |
| Vector<char16_t, 1024> string; |
| unsigned prefixLength = 0; |
| |
| if (requiresContextForWordBoundary(characterAfter(characterOffset))) { |
| auto backwardsScanRange = makeRangeSelectingNodeContents(protect(boundary->document())); |
| if (!setRangeStartOrEndWithCharacterOffset(backwardsScanRange, characterOffset, false)) |
| return { }; |
| prefixLength = prefixLengthForRange(backwardsScanRange, string); |
| } |
| |
| if (!setRangeStartOrEndWithCharacterOffset(searchRange, characterOffset, true)) |
| return { }; |
| CharacterOffset end = startOrEndCharacterOffsetForRange(searchRange, false); |
| |
| TextIterator it(searchRange, TextIteratorBehavior::EmitsObjectReplacementCharacters); |
| 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(Ref { *characterOffset.node }, characterOffset.offset + next - prefixLength); |
| |
| return characterOffset; |
| } |
| |
| // FIXME: Share code with the one in VisibleUnits.cpp. |
| CharacterOffset AXObjectCache::previousBoundary(const CharacterOffset& characterOffset, BoundarySearchFunction searchFunction, NeedsContextAtParagraphStart needsContextAtParagraphStart) |
| { |
| if (characterOffset.isNull()) |
| return CharacterOffset(); |
| |
| RefPtr boundary = parentEditingBoundary(RefPtr { characterOffset.node.get() }.get()); |
| if (!boundary) |
| return CharacterOffset(); |
| |
| auto searchRange = rangeForNodeContents(*boundary); |
| Vector<char16_t, 1024> string; |
| unsigned suffixLength = 0; |
| |
| if (needsContextAtParagraphStart == NeedsContextAtParagraphStart::Yes && startCharacterOffsetOfParagraph(characterOffset).isEqual(characterOffset)) { |
| auto forwardsScanRange = makeRangeSelectingNodeContents(protect(boundary->document())); |
| auto endOfCurrentParagraph = endCharacterOffsetOfParagraph(characterOffset); |
| if (!setRangeStartOrEndWithCharacterOffset(forwardsScanRange, characterOffset, true)) |
| return { }; |
| if (!setRangeStartOrEndWithCharacterOffset(forwardsScanRange, endOfCurrentParagraph, false)) |
| return { }; |
| for (TextIterator forwardsIterator(forwardsScanRange); !forwardsIterator.atEnd(); forwardsIterator.advance()) |
| append(string, forwardsIterator.text()); |
| suffixLength = string.size(); |
| } else if (requiresContextForWordBoundary(characterBefore(characterOffset))) { |
| auto forwardsScanRange = makeRangeSelectingNodeContents(protect(boundary->document())); |
| auto afterBoundary = makeBoundaryPointAfterNode(*boundary); |
| if (!afterBoundary) |
| return { }; |
| forwardsScanRange.start = *afterBoundary; |
| 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; |
| |
| Ref node = (it.atEnd() ? searchRange : it.range()).start.container; |
| |
| // SimplifiedBackwardsTextIterator ignores replaced elements. |
| // Subsequent *characterOffset.node dereferences are safe because we called CharacterOffset.isNull() |
| // at the top of the method. |
| if (AccessibilityObject::replacedNodeNeedsCharacter(protect(*characterOffset.node))) |
| return characterOffsetForNodeAndOffset(protect(*characterOffset.node), 0); |
| RefPtr nextSibling = node->nextSibling(); |
| if (node.ptr() != characterOffset.node.get() && nextSibling && AccessibilityObject::replacedNodeNeedsCharacter(*nextSibling)) |
| return startOrEndCharacterOffsetForRange(rangeForNodeContents(*nextSibling), false); |
| |
| if ((!suffixLength && node->isTextNode() && next <= node->length()) || (node->renderer() && node->renderer()->isBR() && !next)) { |
| // The next variable contains a usable index into a text node |
| if (node->isTextNode()) |
| return traverseToOffsetInRange(rangeForNodeContents(node.get()), next, TraverseOptionValidateOffset); |
| return characterOffsetForNodeAndOffset(node.get(), next, TraverseOptionIncludeStart); |
| } |
| |
| int characterCount = characterOffset.offset; |
| if (next < string.size() - suffixLength) |
| characterCount -= 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(protect(*characterOffset.node), characterCount, TraverseOptionIncludeStart); |
| } |
| |
| CharacterOffset AXObjectCache::startCharacterOffsetOfParagraph(const CharacterOffset& characterOffset, EditingBoundaryCrossingRule boundaryCrossingRule) |
| { |
| if (characterOffset.isNull()) |
| return CharacterOffset(); |
| |
| Ref startNode = *characterOffset.node; |
| |
| if (isRenderedAsNonInlineTableImageOrHR(startNode.ptr())) |
| return startOrEndCharacterOffsetForRange(rangeForNodeContents(startNode), true); |
| |
| auto startBlock = enclosingBlock(startNode.ptr()); |
| int offset = characterOffset.startIndex + characterOffset.offset; |
| auto highestRoot = highestEditableRoot(firstPositionInOrBeforeNode(startNode.ptr())); |
| Position::AnchorType type = Position::PositionIsOffsetInAnchor; |
| |
| Ref node = *findStartOfParagraph(startNode.ptr(), highestRoot.get(), startBlock.get(), 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(); |
| |
| RefPtr startNode = characterOffset.node; |
| if (isRenderedAsNonInlineTableImageOrHR(startNode.get())) |
| return startOrEndCharacterOffsetForRange(rangeForNodeContents(*startNode), false); |
| |
| auto stayInsideBlock = enclosingBlock(startNode.get()); |
| int offset = characterOffset.startIndex + characterOffset.offset; |
| auto highestRoot = highestEditableRoot(firstPositionInOrBeforeNode(startNode.get())); |
| Position::AnchorType type = Position::PositionIsOffsetInAnchor; |
| |
| Ref node = *findEndOfParagraph(startNode.get(), highestRoot.get(), stayInsideBlock.get(), 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); |
| } |
| |
| std::optional<SimpleRange> 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, NeedsContextAtParagraphStart::Yes); |
| } |
| |
| CharacterOffset AXObjectCache::endCharacterOffsetOfSentence(const CharacterOffset& characterOffset) |
| { |
| return nextBoundary(characterOffset, endSentenceBoundary); |
| } |
| |
| std::optional<SimpleRange> 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(); |
| } |
| |
| renderer = characterOffset.node->renderer(); |
| if (!renderer) |
| return LayoutRect(); |
| |
| // Use a collapsed range to get the position. |
| auto range = rangeForUnorderedCharacterOffsets(characterOffset, characterOffset); |
| if (!range) |
| return IntRect(); |
| |
| auto boxAndOffset = makeContainerOffsetPosition(range->start).inlineBoxAndOffset(Affinity::Downstream); |
| if (boxAndOffset.box) |
| renderer = const_cast<RenderObject*>(&boxAndOffset.box->renderer()); |
| |
| if (auto* renderLineBreak = dynamicDowncast<RenderLineBreak>(renderer); renderLineBreak && InlineIterator::boxFor(*renderLineBreak) != boxAndOffset.box) |
| return IntRect(); |
| |
| return computeLocalCaretRect(*renderer, boxAndOffset); |
| } |
| |
| 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, RefPtr { characterOffset.node }.get(), renderer, caretPainter); |
| return absoluteBoundsForLocalCaretRect(caretPainter, localRect); |
| } |
| |
| CharacterOffset AXObjectCache::characterOffsetForPoint(const IntPoint& point, AXCoreObject* object) |
| { |
| if (!object) |
| return { }; |
| auto range = makeSimpleRange(object->visiblePositionForPoint(point)); |
| if (!range) |
| return { }; |
| return startOrEndCharacterOffsetForRange(*range, true); |
| } |
| |
| CharacterOffset AXObjectCache::characterOffsetForPoint(const IntPoint& point) |
| { |
| RefPtr document = m_document.get(); |
| if (!document) |
| return { }; |
| auto range = makeSimpleRange(document->caretPositionFromPoint(point, HitTestSource::User)); |
| if (!range) |
| return { }; |
| return startOrEndCharacterOffsetForRange(*range, 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; |
| |
| auto priorNextCharOffset = nextCharOffset; |
| auto priorPreviousCharOffset = previousCharOffset; |
| nextCharOffset = nextCharacterOffset(nextCharOffset, false); |
| previousCharOffset = previousCharacterOffset(previousCharOffset, false); |
| if (nextCharOffset.isEqual(priorNextCharOffset) && previousCharOffset.isEqual(priorPreviousCharOffset)) { |
| // Without this break, we would loop infinitely. |
| break; |
| } |
| } |
| |
| 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 AXCoreObject* object) |
| { |
| if (!object) |
| return { }; |
| |
| auto visiblePosition = object->visiblePositionForIndex(index); |
| auto characterOffset = characterOffsetFromVisiblePosition(visiblePosition); |
| // In text control, VisiblePosition always gives the before position of a |
| // BR node, while CharacterOffset will do the opposite. |
| if (object->isTextControl() && characterOffsetNodeIsBR(characterOffset)) |
| characterOffset.offset = 1; |
| |
| auto range = object->simpleRange(); |
| if (!range) |
| return { }; |
| |
| auto start = startOrEndCharacterOffsetForRange(*range, true, true); |
| auto end = startOrEndCharacterOffsetForRange(*range, false, true); |
| auto result = start; |
| for (int i = 0; i < index; i++) { |
| if (result.isEqual(characterOffset)) { |
| // Do not include the new line character, always move the offset to the start of next node. |
| if ((characterOffset.node->isTextNode() || characterOffsetNodeIsBR(characterOffset))) { |
| auto next = nextCharacterOffset(characterOffset, false); |
| if (!next.isNull() && !next.offset && rootAXEditableElement(RefPtr { next.node }.get()) == rootAXEditableElement(RefPtr { characterOffset.node }.get())) |
| result = next; |
| } |
| break; |
| } |
| |
| result = nextCharacterOffset(result, false); |
| if (result.isEqual(end)) |
| break; |
| } |
| |
| return result; |
| } |
| |
| const Element* AXObjectCache::rootAXEditableElement(const Node* node) |
| { |
| CheckedPtr<const Element> result = node->rootEditableElement(); |
| CheckedPtr element = dynamicDowncast<Element>(*node); |
| if (!element) |
| element = node->parentElement(); |
| |
| for (; element; element = element->parentElement()) { |
| if (elementIsTextControl(*element)) |
| result = element; |
| } |
| |
| return result.unsafeGet(); |
| } |
| |
| static void conditionallyAddNodeToFilterList(Node* node, const Document& document, HashSet<Ref<Node>>& nodesToRemove) |
| { |
| if (node && (!node->isConnected() || &node->document() == &document)) |
| nodesToRemove.add(*node); |
| } |
| |
| template<typename T> |
| static void filterVectorPairForRemoval(const Vector<std::pair<T, T>>& list, const Document& document, HashSet<Ref<Node>>& nodesToRemove) |
| { |
| for (auto& entry : list) { |
| conditionallyAddNodeToFilterList(entry.first, document, nodesToRemove); |
| conditionallyAddNodeToFilterList(entry.second, document, nodesToRemove); |
| } |
| } |
| |
| template<typename T, typename U> |
| static void filterMapForRemoval(const HashMap<T, U>& list, const Document& document, HashSet<Ref<Node>>& nodesToRemove) |
| { |
| for (auto& entry : list) |
| conditionallyAddNodeToFilterList(entry.key, document, nodesToRemove); |
| } |
| |
| template<typename T> |
| static void filterListForRemoval(const ListHashSet<T>& list, const Document& document, HashSet<Ref<Node>>& nodesToRemove) |
| { |
| for (Ref node : list) |
| conditionallyAddNodeToFilterList(node.ptr(), document, nodesToRemove); |
| } |
| |
| template<typename WeakHashSet> |
| static void filterWeakHashSetForRemoval(WeakHashSet& weakHashSet, const Document& document, HashSet<Ref<Node>>& nodesToRemove) |
| { |
| weakHashSet.forEach([&] (auto& element) { |
| conditionallyAddNodeToFilterList(&element, document, nodesToRemove); |
| }); |
| } |
| |
| template<typename WeakListHashSetType> |
| static void filterWeakListHashSetForRemoval(WeakListHashSetType& list, const Document& document, HashSet<Ref<Node>>& nodesToRemove) |
| { |
| for (Ref node : list) |
| conditionallyAddNodeToFilterList(node.ptr(), document, nodesToRemove); |
| } |
| |
| template<typename WeakHashMapType> |
| static void filterWeakHashMapForRemoval(WeakHashMapType& map, const Document& document, HashSet<Ref<Node>>& nodesToRemove) |
| { |
| for (auto elementEntry : map) |
| conditionallyAddNodeToFilterList(protect(&elementEntry.key), document, nodesToRemove); |
| } |
| |
| void AXObjectCache::prepareForDocumentDestruction(const Document& document) |
| { |
| HashSet<Ref<Node>> nodesToRemove; |
| filterWeakListHashSetForRemoval(m_deferredTextChangedList, document, nodesToRemove); |
| filterWeakListHashSetForRemoval(m_deferredElementAddedOrRemovedList, document, nodesToRemove); |
| filterWeakHashSetForRemoval(m_deferredRecomputeIsIgnoredList, document, nodesToRemove); |
| filterWeakHashSetForRemoval(m_deferredRecomputeTableIsExposedList, document, nodesToRemove); |
| filterWeakHashSetForRemoval(m_deferredSelectedChildredChangedList, document, nodesToRemove); |
| filterWeakHashSetForRemoval(m_deferredModalChangedList, document, nodesToRemove); |
| filterWeakHashSetForRemoval(m_deferredMenuListChange, document, nodesToRemove); |
| filterWeakHashMapForRemoval(m_deferredTextFormControlValue, document, nodesToRemove); |
| m_deferredFocusedNodeChange = std::nullopt; |
| m_deferredRemoteFrameFocus = std::nullopt; |
| |
| for (const auto& entry : m_deferredAttributeChange) { |
| if (entry.element && (!entry.element->isConnected() || &entry.element->document() == &document)) |
| nodesToRemove.add(*entry.element); |
| } |
| |
| for (const auto& element : m_modalElements) { |
| if (element && (!element->isConnected() || &element->document() == &document)) |
| nodesToRemove.add(*element); |
| } |
| |
| for (auto& node : nodesToRemove) |
| remove(node); |
| } |
| |
| bool AXObjectCache::elementIsTextControl(const Element& element) |
| { |
| const RefPtr axObject = getOrCreate(const_cast<Element&>(element)); |
| return axObject && axObject->isTextControl(); |
| } |
| |
| void AXObjectCache::performDeferredCacheUpdate(ForceLayout forceLayout) |
| { |
| AXTRACE(makeString("AXObjectCache::performDeferredCacheUpdate 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| if (m_performingDeferredCacheUpdate) { |
| AXLOG("Bailing out due to reentrant call."); |
| return; |
| } |
| SetForScope performingDeferredCacheUpdate(m_performingDeferredCacheUpdate, true); |
| |
| RefPtr document = this->document(); |
| if (!document) |
| return; |
| |
| // It's unexpected for this function to run in the middle of a render tree or style update. |
| AX_ASSERT(!Accessibility::inRenderTreeOrStyleUpdate(*document)); |
| |
| if (!document->view()) |
| return; |
| |
| if (auto documentNeeds = needsLayoutOrStyleRecalc(*document)) { |
| // Layout became dirty while waiting to performDeferredCacheUpdate, and we require clean layout |
| // to update the accessibility tree correctly in this function. |
| if ((m_cacheUpdateDeferredCount >= 3 || forceLayout == ForceLayout::Yes) && !Accessibility::inRenderTreeOrStyleUpdate(*document)) { |
| // Layout is being thrashed before we get a chance to update, so stop waiting and just force it. |
| m_cacheUpdateDeferredCount = 0; |
| document->updateLayoutIgnorePendingStylesheets(); |
| } else { |
| ++m_cacheUpdateDeferredCount; |
| if (!documentNeeds.contains(DocumentNeeds::Layout) && !m_performCacheUpdateTimer.isActive()) { |
| // A pending style recalc may not necessarily trigger layout, so restart the timer explicitly |
| // to avoid stranding deferred changes. |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| return; |
| } |
| } |
| |
| RefPtr<Frame> frame = document->frame(); |
| if (frame && frame->isMainFrame()) { |
| // The layout of subframes must also be clean (assuming we're processing objects from those subframes), so force it here if necessary. |
| for (; frame; frame = frame->tree().traverseNext()) { |
| auto* localFrame = dynamicDowncast<LocalFrame>(frame.get()); |
| RefPtr subDocument = localFrame ? localFrame->document() : nullptr; |
| if (subDocument && needsLayoutOrStyleRecalc(*subDocument)) |
| subDocument->updateLayoutIgnorePendingStylesheets(); |
| } |
| } |
| |
| bool markedRelationsDirty = false; |
| auto markRelationsDirty = [&] () { |
| if (!markedRelationsDirty) { |
| relationsNeedUpdate(true); |
| markedRelationsDirty = true; |
| } |
| }; |
| |
| AXLOGDeferredCollection("RendererChangedList"_s, m_deferredRendererChangedList); |
| for (Ref object : m_deferredRendererChangedList) { |
| object->updateRole(); |
| object->recomputeIsIgnored(); |
| } |
| m_deferredRendererChangedList.clear(); |
| |
| AXLOGDeferredCollection("RecomputeActiveSummaryList"_s, m_deferredRecomputeActiveSummaryList); |
| for (Ref object : m_deferredRecomputeActiveSummaryList) { |
| object->updateRole(); |
| object->recomputeIsIgnored(); |
| } |
| m_deferredRecomputeActiveSummaryList.clear(); |
| |
| AXLOGDeferredCollection("RecomputeTableIsExposedList"_s, m_deferredRecomputeTableIsExposedList); |
| m_deferredRecomputeTableIsExposedList.forEach([this] (auto& tableElement) { |
| if (RefPtr axObject = get(&tableElement)) |
| axObject->recomputeIsExposableIfNecessary(); |
| }); |
| m_deferredRecomputeTableIsExposedList.clear(); |
| |
| AXLOGDeferredCollection("ChildrenChangedList"_s, m_deferredChildrenChangedList); |
| handleAllDeferredChildrenChanged(); |
| |
| AXLOGDeferredCollection("ElementAddedOrRemovedList"_s, m_deferredElementAddedOrRemovedList); |
| auto elementAddedOrRemovedList = copyToVector(m_deferredElementAddedOrRemovedList); |
| for (auto& weakElement : elementAddedOrRemovedList) { |
| if (RefPtr element = weakElement.get()) { |
| handleMenuOpened(*element); |
| handleLiveRegionCreated(*element); |
| |
| if (element->hasID() && m_unresolvedRelationTargetIds.contains(element->getIdAttribute())) { |
| // A previously-unresolved relation target (e.g. an aria-labelledby target that didn't |
| // exist when relations were last built) was just inserted, so dirty relations to |
| // re-resolve them. |
| markRelationsDirty(); |
| } |
| |
| if (RefPtr label = dynamicDowncast<HTMLLabelElement>(*element)) { |
| // A label was added or removed. Update its LabelFor relationships. |
| m_elementsWithRelationAttributes.add(*label); |
| handleLabelChanged(protect(getOrCreate(*label))); |
| } |
| } |
| } |
| m_deferredElementAddedOrRemovedList.clear(); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| AXLOGDeferredCollection("UnconnectedObjects"_s, m_deferredUnconnectedObjects); |
| if (auto tree = AXIsolatedTree::treeForFrameID(m_frameID)) { |
| m_deferredUnconnectedObjects.forEach([&tree] (auto& object) { |
| tree->addUnconnectedNode(object); |
| }); |
| m_deferredUnconnectedObjects.clear(); |
| } |
| #endif |
| |
| AXLOGDeferredCollection("RecomputeTableCellSlotsList"_s, m_deferredRecomputeTableCellSlotsList); |
| m_deferredRecomputeTableCellSlotsList.forEach([this] (auto& axTable) { |
| handleRecomputeCellSlots(axTable); |
| }); |
| m_deferredRecomputeTableCellSlotsList.clear(); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| AXLOGDeferredCollection("RowspanChangesList"_s, m_deferredRowspanChanges); |
| m_deferredRowspanChanges.forEach([this] (auto& axCell) { |
| handleRowspanChanged(axCell); |
| }); |
| m_deferredRowspanChanges.clear(); |
| #endif |
| |
| AXLOGDeferredCollection("TextChangedList"_s, m_deferredTextChangedList); |
| auto textChangedList = copyToVector(m_deferredTextChangedList); |
| for (auto& weakNode : textChangedList) { |
| if (RefPtr node = weakNode.get()) |
| handleTextChanged(protect(getOrCreate(*node))); |
| } |
| m_deferredTextChangedList.clear(); |
| |
| AXLOGDeferredCollection("RecomputeIsIgnoredList"_s, m_deferredRecomputeIsIgnoredList); |
| m_deferredRecomputeIsIgnoredList.forEach([this] (auto& element) { |
| if (auto* renderer = element.renderer()) |
| recomputeIsIgnored(*renderer); |
| }); |
| m_deferredRecomputeIsIgnoredList.clear(); |
| |
| AXLOGDeferredCollection("SelectedChildredChangedList"_s, m_deferredSelectedChildredChangedList); |
| m_deferredSelectedChildredChangedList.forEach([this] (auto& selectElement) { |
| selectedChildrenChanged(&selectElement); |
| }); |
| m_deferredSelectedChildredChangedList.clear(); |
| |
| AXLOGDeferredCollection("TextFormControlValue"_s, m_deferredTextFormControlValue); |
| for (auto deferredFormControlContext : m_deferredTextFormControlValue) { |
| Ref textFormControlElement = downcast<HTMLTextFormControlElement>(deferredFormControlContext.key); |
| postTextReplacementNotificationForTextControl(textFormControlElement.get(), deferredFormControlContext.value, textFormControlElement->innerTextValue()); |
| } |
| m_deferredTextFormControlValue.clear(); |
| |
| AXLOGDeferredCollection("AttributeChange"_s, m_deferredAttributeChange); |
| for (const auto& attributeChange : borrow(m_deferredAttributeChange).get()) { |
| handleAttributeChange(protect(attributeChange.element.get()), attributeChange.attrName, attributeChange.oldValue, attributeChange.newValue); |
| if (attributeChange.attrName == idAttr && idChangeCanAffectRelations(attributeChange.element.get(), attributeChange.oldValue, attributeChange.newValue)) |
| markRelationsDirty(); |
| } |
| m_deferredAttributeChange.clear(); |
| |
| if (m_deferredFocusedNodeChange) { |
| AXLOG(makeString( |
| "Processing deferred focused node change. Old node "_s, |
| m_deferredFocusedNodeChange->first ? m_deferredFocusedNodeChange->first->debugDescription() : "nullptr"_s, |
| ", new node "_s, |
| m_deferredFocusedNodeChange->second ? m_deferredFocusedNodeChange->second->debugDescription() : "nullptr"_s |
| )); |
| // Don't update the modal with this focus change since it may need to be updated again as a result of processing m_deferredModalChangedList below. |
| handleFocusedUIElementChanged(protect(m_deferredFocusedNodeChange->first.get()), protect(m_deferredFocusedNodeChange->second.get()), UpdateModal::No); |
| // Recompute isIgnored after a focus change in case that altered visibility. |
| recomputeIsIgnored(protect(m_deferredFocusedNodeChange->first.get())); |
| recomputeIsIgnored(protect(m_deferredFocusedNodeChange->second.get())); |
| } |
| // If we changed the focused element, that could affect what modal should be active, so recompute it. |
| bool shouldRecomputeModal = m_deferredFocusedNodeChange.has_value(); |
| RefPtr newFocusElement = m_deferredFocusedNodeChange ? m_deferredFocusedNodeChange->second.get() : nullptr; |
| m_deferredFocusedNodeChange = std::nullopt; |
| |
| if (m_deferredRemoteFrameFocus) { |
| AXLOG(makeString( |
| "Processing deferred remote frame focus. Old element "_s, |
| m_deferredRemoteFrameFocus->oldFocusedElement ? m_deferredRemoteFrameFocus->oldFocusedElement->debugDescription() : "nullptr"_s |
| )); |
| if (RefPtr remoteFrame = m_deferredRemoteFrameFocus->remoteFrame.get()) |
| handleRemoteFrameGainedFocus(*remoteFrame, protect(m_deferredRemoteFrameFocus->oldFocusedElement.get())); |
| shouldRecomputeModal = true; |
| m_deferredRemoteFrameFocus = std::nullopt; |
| } |
| |
| AXLOGDeferredCollection("ModalChangedList"_s, m_deferredModalChangedList); |
| for (Ref element : m_deferredModalChangedList) { |
| if (!is<HTMLDialogElement>(element) && !hasAnyRole(element.get(), { "dialog"_s, "alertdialog"_s })) |
| continue; |
| shouldRecomputeModal = true; |
| |
| if (!m_modalNodesInitialized) [[unlikely]] |
| findModalNodes(); |
| |
| if (isModalElement(element.get())) { |
| // Add the newly modified node to the modal nodes set. |
| // We will recompute the current valid aria modal node in modalNode() when this node is not visible. |
| m_modalElements.append(element.ptr()); |
| } else { |
| m_modalElements.removeAllMatching([&element] (const auto& modalElement) { |
| return element.ptr() == modalElement.get(); |
| }); |
| } |
| } |
| m_deferredModalChangedList.clear(); |
| |
| if (shouldRecomputeModal) |
| updateCurrentModalNode(); |
| |
| AXLOGDeferredCollection("MenuListChange"_s, m_deferredMenuListChange); |
| m_deferredMenuListChange.forEach([this] (auto& element) { |
| handleMenuListValueChanged(element); |
| }); |
| m_deferredMenuListChange.clear(); |
| |
| m_deferredScrollbarUpdateChangeList.forEach([this] (auto& scrollView) { |
| handleScrollbarUpdate(scrollView); |
| }); |
| m_deferredScrollbarUpdateChangeList.clear(); |
| |
| AXLOGDeferredCollection("CanvasFocusPathBoundsChanges"_s, m_deferredCanvasFocusPathBoundsChanges); |
| for (const auto& change : m_deferredCanvasFocusPathBoundsChanges) |
| handleCanvasFocusPathBoundsChange(change); |
| m_deferredCanvasFocusPathBoundsChanges.clear(); |
| |
| for (const auto& notificationData : m_deferredNotifications) |
| handleDeferredNotification(notificationData); |
| m_deferredNotifications.clear(); |
| |
| for (auto& toggledPopover : m_deferredToggledPopovers) |
| handleDeferredPopoverToggle(toggledPopover); |
| m_deferredToggledPopovers.clear(); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (m_deferredRegenerateIsolatedTree) { |
| if (auto tree = AXIsolatedTree::treeForFrameID(m_frameID)) { |
| // Re-generate the subtree rooted at the webarea. |
| if (RefPtr webArea = rootWebArea()) { |
| AXLOG("Regenerating isolated tree from AXObjectCache::performDeferredCacheUpdate()."); |
| // m_deferredRegenerateIsolatedTree is only set when we change the active modal, which effects the ignored |
| // status of every object on the page. This means we only have to re-compute is-ignored for every object, |
| // rather than re-compute all objects entirely. |
| tree->updatePropertiesForSelfAndDescendants(*webArea, { AXProperty::IsIgnored }); |
| |
| // In some cases, the ID of the focus after a dialog pops up doesn't match the ID in the last focus change notification, creating a mismatch between the isolated tree cached focused object ID and the actual focused object ID. |
| // For this reason, reset the focused object ID. |
| if (RefPtr focus = focusedObjectForLocalFrame()) |
| tree->setFocusedNodeID(focus->objectID()); |
| } |
| } |
| } |
| m_deferredRegenerateIsolatedTree = false; |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| |
| #if PLATFORM(COCOA) |
| // Initialize the live region manager after the first cache update, so all existing |
| // live regions are registered with their initial state before any changes occur. |
| if (!m_liveRegionManagerInitialized) |
| initializeLiveRegionManager(); |
| #endif |
| |
| platformPerformDeferredCacheUpdate(); |
| |
| #if PLATFORM(COCOA) |
| if (m_liveRegionManager && !m_changedLiveRegions.isEmpty()) { |
| m_liveRegionChangedPostTimer.stop(); |
| processChangedLiveRegions(); |
| } |
| #endif |
| } |
| |
| void AXObjectCache::handleDeferredPopoverToggle(AccessibilityObject& axPopover) |
| { |
| if (RefPtr popoverElement = dynamicDowncast<SelectPopoverElement>(axPopover.element())) { |
| // When a base-appearance select's popover toggles, post ExpandedChanged on the |
| // select element itself, since the popover is opened programmatically (not |
| // via popovertarget/commandfor) so controllers() would be empty. |
| if (RefPtr selectElement = popoverElement->selectElement()) { |
| if (RefPtr axSelect = get(selectElement.get())) |
| postNotification(axSelect.get(), protect(document()), AXNotification::ExpandedChanged); |
| |
| bool isOpening = popoverElement->isPopoverShowing(); |
| if (isOpening) { |
| RefPtr option = selectElement->selectedOption(); |
| if (option && (selectElement->focused() || option->focused())) { |
| // This notification isn't strictly necessary, as when handling press() via the |
| // accessibility API, the DOM will naturally move focus to the selected option. |
| // However, in practice, I've found a notification here to make VoiceOver's focus |
| // movement significantly more snappy and consistent. |
| if (RefPtr axOption = getOrCreate(*option)) |
| postNotification(axOption.get(), protect(document()), AXNotification::FocusedUIElementChanged); |
| } |
| } |
| } |
| |
| return; |
| } |
| |
| // There may be multiple elements with popovertarget or commandfor attributes that point at this popover. |
| for (const auto& invoker : axPopover.controllers()) |
| postNotification(&downcast<AccessibilityObject>(invoker.get()), protect(document()), AXNotification::ExpandedChanged); |
| } |
| |
| void AXObjectCache::handleDeferredNotification(const DeferredNotificationData& data) |
| { |
| if (CheckedPtr renderer = data.renderer.get()) { |
| // The point of deferring the notification is to post it when style and layout |
| // are clean, so this function should not be called unless this is true. |
| AX_ASSERT(!needsLayoutOrStyleRecalc(renderer->document())); |
| AX_ASSERT(!renderer->needsLayout()); |
| postNotification(protect(getOrCreate(*renderer)), data.notification); |
| } else if (RefPtr element = data.element.get()) { |
| AX_ASSERT(!needsLayoutOrStyleRecalc(element->document())); |
| postNotification(protect(getOrCreate(*element)), data.notification); |
| } |
| } |
| |
| void AXObjectCache::postDeferredNotification(RenderObject& renderer, AXNotification notification) |
| { |
| // We want to post this notification, but it may not be safe to call getOrCreate right now |
| // (e.g. because style or layout is dirty). Defer to a time we know the page state is clean. |
| m_deferredNotifications.append(DeferredNotificationData { renderer, notification }); |
| } |
| |
| void AXObjectCache::handleMenuListValueChanged(Element& element) |
| { |
| RefPtr<AccessibilityObject> object = get(&element); |
| if (!object) |
| return; |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| updateIsolatedTree(*object, AXNotification::MenuListValueChanged); |
| #endif |
| |
| postPlatformNotification(*object, AXNotification::MenuListValueChanged); |
| } |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| void AXObjectCache::updateIsolatedTree(AccessibilityObject* object, AXNotification notification) |
| { |
| if (object) |
| updateIsolatedTree(*object, notification); |
| } |
| |
| void AXObjectCache::updateIsolatedTree(AccessibilityObject& object, AXNotification notification) |
| { |
| updateIsolatedTree({ std::make_pair(Ref { object }, notification) }); |
| } |
| |
| void AXObjectCache::updateIsolatedTree(const Vector<std::pair<Ref<AccessibilityObject>, AXNotificationWithData>>& notifications) |
| { |
| AXTRACE(makeString("AXObjectCache::updateIsolatedTree 0x"_s, hex(reinterpret_cast<uintptr_t>(this)))); |
| |
| RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID); |
| if (!tree) { |
| AXLOG("No isolated tree for m_frameID"); |
| return; |
| } |
| |
| HashSet<AXID> idsWithUpdatedDependentProperties; |
| auto updateDependentProperties = [&] (const Ref<AccessibilityObject>& axObject) { |
| if (idsWithUpdatedDependentProperties.add(axObject->objectID()).isNewEntry) |
| tree->updateDependentProperties(axObject.get()); |
| }; |
| |
| for (const auto& notification : notifications) { |
| AXLOG(notification); |
| if (notification.first->isDetached()) |
| continue; |
| |
| switch (notification.second.notification) { |
| case AXNotification::AbbreviationChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::Abbreviation }); |
| break; |
| case AXNotification::AccessKeyChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::AccessKey }); |
| break; |
| case AXNotification::AutofillTypeChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::ValueAutofillButtonType }); |
| break; |
| case AXNotification::ARIAColumnIndexChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::AXColumnIndex }); |
| break; |
| case AXNotification::ARIAColumnIndexTextChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::AXColumnIndexText }); |
| break; |
| case AXNotification::ARIARoleDescriptionChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::ARIARoleDescription }); |
| break; |
| case AXNotification::ARIARowIndexChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::AXRowIndex }); |
| break; |
| case AXNotification::ARIARowIndexTextChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::AXRowIndexText }); |
| break; |
| case AXNotification::BrailleLabelChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::BrailleLabel }); |
| break; |
| case AXNotification::BrailleRoleDescriptionChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::BrailleRoleDescription }); |
| break; |
| case AXNotification::CellSlotsChanged: |
| AX_ASSERT(notification.first->isTable()); |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::CellSlots }); |
| break; |
| case AXNotification::CheckedStateChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::IsChecked }); |
| break; |
| case AXNotification::CurrentStateChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::CurrentState }); |
| break; |
| case AXNotification::CursorTypeChanged: |
| tree->updatePropertiesForSelfAndDescendants(notification.first.get(), { AXProperty::HasCursorPointer }); |
| break; |
| case AXNotification::ColumnCountChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::AXColumnCount }); |
| break; |
| case AXNotification::ColumnIndexChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { { AXProperty::ColumnIndexRange, AXProperty::ColumnIndex } }); |
| break; |
| case AXNotification::ColumnSpanChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::ColumnIndexRange }); |
| break; |
| case AXNotification::CommandChanged: |
| case AXNotification::CommandForChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { { AXProperty::SupportsExpanded, AXProperty::IsExpanded } }); |
| break; |
| case AXNotification::DatetimeChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::DatetimeAttributeValue }); |
| break; |
| case AXNotification::DisabledStateChanged: |
| tree->updatePropertiesForSelfAndDescendants(notification.first.get(), { { AXProperty::CanSetFocusAttribute, AXProperty::CanSetSelectedAttribute, AXProperty::IsEnabled } }); |
| break; |
| case AXNotification::DraggableStateChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::SupportsDragging }); |
| break; |
| case AXNotification::ExpandedChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::IsExpanded }); |
| break; |
| case AXNotification::FocusedUIElementChanged: { |
| // If we're going to inform ATs that the focus has changed, use the handling |
| // of this notification to ensure the isolated tree has the latest focused node. |
| // This is especially important for ATs like VoiceOver who immediately respond |
| // to this notification with a request for the currenly focused object. If we |
| // serve stale data in this scenario, VoiceOver will never move its cursor. |
| RefPtr focus = focusedObjectForLocalFrame(); |
| tree->setFocusedNodeID(focus ? std::optional { focus->objectID() } : std::nullopt); |
| break; |
| } |
| case AXNotification::ExtendedDescriptionChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { { AXProperty::AccessibilityText, AXProperty::ExtendedDescription } }); |
| break; |
| case AXNotification::FocusableStateChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::CanSetFocusAttribute }); |
| break; |
| case AXNotification::FontChanged: |
| tree->updatePropertiesForSelfAndDescendants(notification.first.get(), { AXProperty::Font }); |
| break; |
| case AXNotification::HiddenStateChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::IsHiddenUntilFoundContainer }); |
| break; |
| case AXNotification::InertOrVisibilityChanged: |
| tree->updatePropertiesForSelfAndDescendants(notification.first.get(), { AXProperty::IsIgnored }); |
| break; |
| case AXNotification::InputTypeChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::InputType }); |
| break; |
| case AXNotification::IsEditableWebAreaChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::IsEditableWebArea }); |
| break; |
| case AXNotification::LevelChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::ARIALevel }); |
| break; |
| case AXNotification::HeadingLevelChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::HeadingLevel }); |
| break; |
| case AXNotification::MaximumValueChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { { AXProperty::MaxValueForRange, AXProperty::ValueForRange } }); |
| break; |
| case AXNotification::MenuListItemSelected: { |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::IsSelected }); |
| break; |
| } |
| case AXNotification::MinimumValueChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { { AXProperty::MinValueForRange, AXProperty::ValueForRange } }); |
| break; |
| case AXNotification::NameChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::NameAttribute }); |
| break; |
| case AXNotification::OrientationChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::ExplicitOrientation }); |
| break; |
| case AXNotification::PositionInSetChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { { AXProperty::PosInSet, AXProperty::SupportsPosInSet } }); |
| break; |
| case AXNotification::PointerEventsChanged: |
| tree->updatePropertiesForSelfAndDescendants(notification.first.get(), { { AXProperty::HasCursorPointer, AXProperty::HasPointerEventsNone } }); |
| break; |
| case AXNotification::PopoverTargetChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { { AXProperty::SupportsExpanded, AXProperty::IsExpanded } }); |
| break; |
| case AXNotification::PressDidSucceed: |
| // The only presses that post this toggle a select's picker, so the expanded state |
| // has just changed. Refresh it in the same batch that posts the notification so |
| // an AT that queries in response gets the right value. |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::IsExpanded }); |
| break; |
| case AXNotification::SelectedTextChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::SelectedTextRange }); |
| break; |
| case AXNotification::SortDirectionChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::SortDirection }); |
| break; |
| case AXNotification::IdAttributeChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::IdentifierAttribute }); |
| break; |
| case AXNotification::RadioGroupMembershipChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::RadioButtonGroupMembers }); |
| break; |
| case AXNotification::ReadOnlyStatusChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::CanSetValueAttribute }); |
| break; |
| case AXNotification::RequiredStatusChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::IsRequired }); |
| break; |
| case AXNotification::RowIndexChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { { AXProperty::RowIndexRange, AXProperty::RowIndex } }); |
| break; |
| case AXNotification::RowSpanChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::RowIndexRange }); |
| break; |
| case AXNotification::CellScopeChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { { AXProperty::CellScope, AXProperty::IsColumnHeader, AXProperty::IsRowHeader } }); |
| break; |
| // FIXME: Contrary to the name "AXSelectedCellsChanged", this notification can be posted on a cell |
| // who has changed selected state, not just on table or grid who has changed its selected cells. |
| case AXNotification::SelectedCellsChanged: |
| case AXNotification::SelectedStateChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::IsSelected }); |
| break; |
| case AXNotification::SetSizeChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { { AXProperty::SetSize, AXProperty::SupportsSetSize } }); |
| break; |
| case AXNotification::SpeakAsChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::SpeakAs }); |
| break; |
| case AXNotification::StyleChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::ShowsCursorOnHover }); |
| break; |
| case AXNotification::TextColorChanged: |
| tree->updatePropertiesForSelfAndDescendants(notification.first.get(), { AXProperty::TextColor }); |
| break; |
| case AXNotification::TextCompositionChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::TextInputMarkedTextMarkerRange }); |
| break; |
| case AXNotification::TextUnderElementChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::AccessibilityText }); |
| if (notification.first->isNativeLabel() || notification.first->role() == AccessibilityRole::TextField) |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::StringValue }); |
| break; |
| case AXNotification::URLChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { { AXProperty::URL, AXProperty::InternalLinkElement } }); |
| break; |
| case AXNotification::KeyShortcutsChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::KeyShortcuts }); |
| break; |
| case AXNotification::VisibilityChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::IsVisible }); |
| break; |
| case AXNotification::VisitedStateChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), { AXProperty::IsVisited }); |
| break; |
| case AXNotification::ActiveDescendantChanged: |
| case AXNotification::RoleChanged: |
| case AXNotification::ControlledObjectsChanged: |
| case AXNotification::DescribedByChanged: |
| case AXNotification::DropEffectChanged: |
| case AXNotification::ElementBusyChanged: |
| case AXNotification::FlowToChanged: |
| case AXNotification::GrabbedStateChanged: |
| case AXNotification::HasPopupChanged: |
| case AXNotification::InvalidStatusChanged: |
| case AXNotification::IsAtomicChanged: |
| case AXNotification::LiveRegionStatusChanged: |
| case AXNotification::LiveRegionRelevantChanged: |
| case AXNotification::PlaceholderChanged: |
| case AXNotification::MenuListValueChanged: |
| case AXNotification::MultiSelectableStateChanged: |
| case AXNotification::PressedStateChanged: |
| case AXNotification::TextChanged: |
| case AXNotification::TextSecurityChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), NodeUpdateOptions::nodeUpdate()); |
| break; |
| case AXNotification::ValueChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), NodeUpdateOptions::nodeUpdate()); |
| // A text control's value and selection must stay consistent for clients that read the |
| // selection in response to this notification, so push the current selection alongside the |
| // value rather than letting it arrive later on the selection-change channel. |
| if (notification.first->isTextControl()) |
| onSelectedTextChanged(notification.first->selectedVisiblePositionRange(), notification.first.ptr()); |
| break; |
| case AXNotification::LabelChanged: { |
| tree->queueNodeUpdate(notification.first->objectID(), NodeUpdateOptions::nodeUpdate()); |
| updateDependentProperties(notification.first); |
| break; |
| } |
| case AXNotification::LanguageChanged: |
| case AXNotification::RowCountChanged: |
| tree->queueNodeUpdate(notification.first->objectID(), NodeUpdateOptions::nodeUpdate()); |
| [[fallthrough]]; |
| case AXNotification::RowCollapsed: |
| case AXNotification::RowExpanded: |
| tree->queueNodeUpdate(notification.first->objectID(), NodeUpdateOptions::childrenUpdate()); |
| break; |
| default: |
| break; |
| } |
| } |
| } |
| |
| void AXObjectCache::updateIsolatedTree(AccessibilityObject* axObject, AXProperty property) const |
| { |
| if (axObject) |
| updateIsolatedTree(*axObject, property); |
| } |
| |
| void AXObjectCache::updateIsolatedTree(AccessibilityObject& axObject, AXProperty property) const |
| { |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->queueNodeUpdate(axObject.objectID(), { property }); |
| } |
| |
| void AXObjectCache::startUpdateTreeSnapshotTimer() |
| { |
| if (!m_updateTreeSnapshotTimer.isActive()) |
| m_updateTreeSnapshotTimer.startOneShot(updateTreeSnapshotTimerDuration); |
| } |
| |
| void AXObjectCache::onPaint(const RenderObject& renderer, IntRect&& paintRect) const |
| { |
| if (std::optional axID = getAXID(const_cast<RenderObject&>(renderer))) { |
| auto paintRectOrigin = paintRect.location(); |
| bool cachedNewRect = m_geometryManager->cacheRectIfNeeded(*axID, WTF::move(paintRect)); |
| |
| if (cachedNewRect) { |
| auto* renderImage = dynamicDowncast<RenderImage>(renderer); |
| if (RefPtr imageMap = renderImage ? renderImage->imageMap() : nullptr) { |
| // <area> elements have no renderers and thus will never be painted themselves. |
| // If the image was repainted in a new location, the associated area elements |
| // probably need new rects and paths cached too. |
| for (Ref area : descendantsOfType<HTMLAreaElement>(*imageMap)) { |
| if (RefPtr areaObject = get(area.get())) { |
| bool areaCachedNewRect = m_geometryManager->cacheRectIfNeeded(areaObject->objectID(), snappedIntRect(LayoutRect(areaObject->relativeFrame()))); |
| if (areaCachedNewRect) |
| m_geometryManager->cachePathForID(areaObject->objectID(), WTF::makeUnique<Path>(areaObject->elementPath())); |
| } |
| } |
| } |
| |
| // FIXME: SVG shapes (RenderSVGShape / LegacyRenderSVGShape) never trigger |
| // AccessibilityRegionContext::takeBounds, so this onPaint is never called for |
| // them and their paths aren't cached in the geometry manager. This means |
| // AXIsolatedObject::elementPath() returns empty for SVG in isolated tree mode. |
| |
| // Some assistive technologies (e.g. VoiceOver) use the path to draw their cursor. |
| // Inflating the path a bit makes the cursor look better. |
| static constexpr unsigned shapePathInflationPx = 4; |
| |
| // Recompute the element path when the rect changes for border-radius or |
| // clip-path boxes. Multi-line inlines compute their path on-demand from |
| // text runs (see AXIsolatedObject::elementPath). |
| if (auto* renderBox = dynamicDowncast<RenderBox>(renderer)) { |
| if (renderBox->hasClipPath()) { |
| WTF::switchOn(renderBox->style().clipPath(), |
| [&](const Style::BasicShapePath& clipPath) { |
| auto borderRect = renderBox->borderBoxRect(); |
| borderRect.inflate(shapePathInflationPx); |
| auto referenceBox = FloatRect(WTF::move(borderRect)); |
| auto path = Style::path(clipPath.shape(), referenceBox, renderBox->style().usedZoomForLength()); |
| path.transform(AffineTransform().translate(paintRectOrigin.x(), paintRectOrigin.y())); |
| m_geometryManager->cachePathForID(*axID, WTF::makeUnique<Path>(WTF::move(path))); |
| }, |
| [](const auto&) { } |
| ); |
| } else if (renderBox->style().border().hasBorderRadius()) { |
| auto borderRect = renderBox->borderBoxRect(); |
| borderRect.inflate(shapePathInflationPx); |
| auto borderShape = BorderShape::shapeForBorderRect(renderBox->style(), WTF::move(borderRect)); |
| auto path = borderShape.pathForOuterShape(renderer.document().deviceScaleFactor()); |
| // borderBoxRect() is in local coordinates starting at (0, 0). Use the paint |
| // rect's origin to position the path, since it's already gone through |
| // contentsToRootView and matches the relativeFrame coordinate system. |
| path.transform(AffineTransform().translate(paintRectOrigin.x(), paintRectOrigin.y())); |
| m_geometryManager->cachePathForID(*axID, WTF::makeUnique<Path>(WTF::move(path))); |
| } |
| } |
| } |
| } |
| } |
| |
| void AXObjectCache::onPaint(const Widget& widget, IntRect&& paintRect) const |
| { |
| if (std::optional axID = m_widgetIdMapping.getOptional(const_cast<Widget&>(widget))) |
| std::ignore = m_geometryManager->cacheRectIfNeeded(*axID, WTF::move(paintRect)); |
| } |
| |
| void AXObjectCache::onPaint(const RenderText& renderText, size_t lineIndex) |
| { |
| AX_ASSERT(isMainThread()); |
| |
| auto ensureResult = m_mostRecentlyPaintedText.ensure(renderText, [&] { |
| return LineRange(lineIndex, lineIndex); |
| }); |
| if (!ensureResult.isNewEntry) { |
| auto iterator = ensureResult.iterator; |
| // The goal is for the LineRange we cache to store the first painted line, and last painted line. |
| // So check the |lineIndex| we were given and adjust the cached values if necessary. |
| if (iterator->value.startLineIndex > lineIndex) |
| iterator->value.startLineIndex = lineIndex; |
| |
| if (iterator->value.endLineIndex < lineIndex) |
| iterator->value.endLineIndex = lineIndex; |
| } |
| } |
| #endif // ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| |
| void AXObjectCache::deferCanvasFocusPathBoundsUpdate(Element& fallbackElement, HTMLCanvasElement& canvas, FloatRect bounds) |
| { |
| m_deferredCanvasFocusPathBoundsChanges.append({ fallbackElement, canvas, bounds }); |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| |
| std::optional<IntRect> AXObjectCache::cachedBoundsForID(AXID axID) const |
| { |
| return m_geometryManager->cachedRectForID(axID); |
| } |
| |
| void AXObjectCache::handleCanvasFocusPathBoundsChange(const CanvasFocusPathBoundsChange& change) |
| { |
| RefPtr fallbackElement = change.fallbackElement.get(); |
| RefPtr canvas = change.canvas.get(); |
| if (!fallbackElement || !canvas) |
| return; |
| |
| RefPtr axObject = getOrCreate(*fallbackElement); |
| if (!axObject) |
| return; |
| |
| // change.bounds is in the canvas renderer's local coordinate space |
| // (positioned within the replacedContentRect). Map to document-absolute |
| // coordinates, which applies CSS transforms on the canvas and its ancestors. |
| auto documentRelativeBounds = FloatRect(change.bounds); |
| if (CheckedPtr renderer = canvas->renderer()) |
| documentRelativeBounds = renderer->localToAbsoluteQuad(FloatQuad(documentRelativeBounds)).boundingBox(); |
| |
| std::ignore = m_geometryManager->cacheRectIfNeeded(axObject->objectID(), enclosingIntRect(documentRelativeBounds)); |
| |
| postPlatformNotification(*axObject, AXNotification::LayoutComplete); |
| } |
| |
| void AXObjectCache::deferRecomputeIsIgnoredIfNeeded(Element* element) |
| { |
| if (!nodeAndRendererAreValid(element)) |
| return; |
| |
| SingleThreadWeakRef<RenderObject> renderer = *element->renderer(); |
| if (rendererNeedsDeferredUpdate(renderer.get())) { |
| m_deferredRecomputeIsIgnoredList.add(*element); |
| return; |
| } |
| recomputeIsIgnored(renderer.get()); |
| } |
| |
| void AXObjectCache::deferRecomputeIsIgnored(Element* element) |
| { |
| if (!nodeAndRendererAreValid(element)) |
| return; |
| |
| m_deferredRecomputeIsIgnoredList.add(*element); |
| } |
| |
| void AXObjectCache::deferRecomputeTableIsExposed(Element* element) |
| { |
| RefPtr tableElement = dynamicDowncast<HTMLTableElement>(element); |
| if (!tableElement) |
| return; |
| |
| m_deferredRecomputeTableIsExposedList.add(*tableElement); |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| |
| void AXObjectCache::deferRecomputeTableCellSlots(AccessibilityNodeObject& axTable) |
| { |
| m_deferredRecomputeTableCellSlotsList.add(axTable); |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| } |
| |
| void AXObjectCache::deferRowspanChange(AccessibilityObject* axObject) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| RefPtr nodeObject = dynamicDowncast<AccessibilityNodeObject>(axObject); |
| if (!nodeObject || !nodeObject->isTableCell()) |
| return; |
| |
| m_deferredRowspanChanges.add(*nodeObject); |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| #else |
| UNUSED_PARAM(axObject); |
| #endif |
| } |
| |
| void AXObjectCache::deferTextChangedIfNeeded(Node* node) |
| { |
| if (!nodeAndRendererAreValid(node)) |
| return; |
| |
| if (rendererNeedsDeferredUpdate(*node->renderer())) { |
| m_deferredTextChangedList.add(*node); |
| return; |
| } |
| handleTextChanged(protect(getOrCreate(*node))); |
| } |
| |
| void AXObjectCache::deferSelectedChildrenChangedIfNeeded(Element& selectElement) |
| { |
| if (!nodeRendererIsValid(selectElement)) |
| return; |
| |
| if (rendererNeedsDeferredUpdate(*selectElement.renderer())) { |
| m_deferredSelectedChildredChangedList.add(selectElement); |
| if (!m_performCacheUpdateTimer.isActive()) |
| m_performCacheUpdateTimer.startOneShot(0_s); |
| return; |
| } |
| selectedChildrenChanged(&selectElement); |
| } |
| |
| void AXObjectCache::deferTextReplacementNotificationForTextControl(HTMLTextFormControlElement& formControlElement, const String& previousValue) |
| { |
| CheckedPtr renderer = formControlElement.renderer(); |
| if (!renderer) |
| return; |
| m_deferredTextFormControlValue.add(formControlElement, previousValue); |
| } |
| |
| bool isNodeFocused(Node& node) |
| { |
| return is<Element>(node) && uncheckedDowncast<Element>(node).focused(); |
| } |
| |
| bool isVisibilityHidden(const Style::ComputedStyle& style) |
| { |
| return style.usedVisibility() != Visibility::Visible || isContentVisibilityHidden(style); |
| } |
| |
| // DOM component of hidden definition. |
| // https://www.w3.org/TR/wai-aria/#dfn-hidden |
| bool isRenderHidden(const Style::ComputedStyle& style) |
| { |
| return style.display() == Style::DisplayType::None || isVisibilityHidden(style); |
| } |
| |
| bool isRenderHidden(const Style::ComputedStyle* style) |
| { |
| return style ? isRenderHidden(*style) : true; |
| } |
| |
| AccessibilityObject* AXObjectCache::rootWebArea() |
| { |
| if (!m_document) |
| return nullptr; |
| RefPtr root = getOrCreate(protect(m_document->view())); |
| if (!root || !root->isScrollArea()) |
| return nullptr; |
| return root->webAreaObject(); |
| } |
| |
| AXTreeData AXObjectCache::treeData(std::optional<OptionSet<AXStreamOptions>> additionalOptions) |
| { |
| AX_ASSERT(isMainThread()); |
| |
| AXTreeData data; |
| TextStream stream(TextStream::LineMode::MultipleLine); |
| |
| stream << "\nAXObjectTree:\n"; |
| stream << "Loading progress: " << m_loadingProgress << "\n"; |
| |
| RefPtr document = this->document(); |
| if (document) { |
| if (RefPtr liveFocus = focusedObjectForPage(protect(document->page()))) |
| stream << "Focused object: ID=" << liveFocus->objectID().loggingString() << " role=" << liveFocus->rolePlatformString() << "\n"; |
| else |
| stream << "Focused object: none\n"; |
| } |
| |
| stream << "Page activity state: " << m_pageActivityState << "\n"; |
| |
| if (RefPtr modalElement = m_currentModalElement.get()) { |
| if (RefPtr modalObject = get(modalElement.get())) |
| stream << "Current modal element: ID=" << modalObject->objectID().loggingString() << " role=" << modalObject->rolePlatformString() << "\n"; |
| else |
| stream << "Current modal element: <" << modalElement->tagName() << "> (no AX object)\n"; |
| } |
| |
| if (RefPtr root = document ? get(protect(document->view())) : nullptr) { |
| OptionSet<AXStreamOptions> options = { AXStreamOptions::ObjectID, AXStreamOptions::ParentID, AXStreamOptions::Role }; |
| if (additionalOptions) |
| options |= additionalOptions.value(); |
| streamSubtree(stream, *root, options); |
| } else |
| stream << "No root!"; |
| |
| data.liveTree = stream.release(); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (isIsolatedTreeEnabled()) { |
| // Verify the frame-loader client (typically the web page) actually has an isolated tree set. |
| if (!m_document) |
| data.warnings.append("Couldn't verify the frame-loader client had an isolated tree because the cache has no m_document."_s); |
| else if (!m_document->frame()) |
| data.warnings.append("Couldn't verify the frame-loader client had an isolated tree because the cache's document had no frame."_s); |
| else if (!m_document->frame()->loader().client().isolatedTree()) |
| data.warnings.append("The frame-loader client (typically the webpage object) had no isolated tree set!"_s); |
| |
| stream << "\nAXIsolatedTree:\n"; |
| |
| RefPtr tree = getOrCreateIsolatedTree(); |
| if (tree) { |
| stream << "Loading progress: " << tree->loadingProgress() << ", isEmptyContentTree: " << (tree->isEmptyContentTree() ? "yes" : "no") << ", nodeMapSize: " << tree->nodeMapSize() << "\n"; |
| if (auto focusID = tree->unsafeFocusedNodeID()) |
| stream << "Focused object: ID=" << focusID->loggingString() << "\n"; |
| else |
| stream << "Focused object: none\n"; |
| |
| stream << "Page activity state: " << tree->pageActivityState() << "\n"; |
| if (RefPtr replacingTree = tree->replacingTreeForLogging()) |
| stream << "Has m_replacingTree (isEmptyContentTree=" << (replacingTree->isEmptyContentTree() ? "yes" : "no") << ", nodeMapSize=" << replacingTree->nodeMapSize() << ")\n"; |
| } |
| |
| if (RefPtr root = tree ? tree->rootNode() : nullptr) { |
| OptionSet<AXStreamOptions> options = { AXStreamOptions::ObjectID, AXStreamOptions::ParentID, AXStreamOptions::Role }; |
| if (additionalOptions) |
| options |= additionalOptions.value(); |
| streamSubtree(stream, root.releaseNonNull(), options); |
| } else if (tree) { |
| // Trailing space is intentional to provide separation from the next sentence. |
| stream << "Isolated tree present, but there was no root! "; |
| |
| // There's no root — maybe there's a pending ID that can't be hydrated into an actual root object? |
| if (std::optional pendingRootID = tree->pendingRootNodeID()) |
| stream << "Has pending root ID " << *pendingRootID << ". Object exists for that ID in the isolated tree: " << tree->unsafeHasObjectForID(*pendingRootID) << ". "; |
| else |
| stream << "No pending root ID. "; |
| |
| if (std::optional rootID = tree->unsafeRootNodeID()) |
| stream << "Has root ID " << *rootID << ". Object exists for that ID in the isolated tree: " << tree->unsafeHasObjectForID(*rootID) << "."; |
| else |
| stream << "No root ID."; |
| } else |
| stream << "No isolated tree!"; |
| |
| data.isolatedTree = stream.release(); |
| } else |
| data.isolatedTree = "Isolated tree mode was off."_s; |
| #endif |
| |
| return data; |
| } |
| |
| Vector<QualifiedName>& AXObjectCache::relationAttributes() |
| { |
| // Keep this list in sync with the switch in isRelationAttribute(). |
| static NeverDestroyed<Vector<QualifiedName>> relationAttributes = Vector<QualifiedName> { |
| aria_actionsAttr, |
| aria_activedescendantAttr, |
| aria_controlsAttr, |
| aria_describedbyAttr, |
| aria_detailsAttr, |
| aria_errormessageAttr, |
| aria_flowtoAttr, |
| aria_labelledbyAttr, |
| aria_labeledbyAttr, |
| aria_ownsAttr, |
| commandforAttr, |
| headersAttr, |
| popovertargetAttr, |
| }; |
| return relationAttributes; |
| } |
| |
| bool AXObjectCache::isRelationAttribute(const QualifiedName& attribute) |
| { |
| // A faster equivalent of relationAttributes().contains(attribute) for the DOM-mutation hot |
| // path (Element::attributeChanged). Keep this in sync with the list in relationAttributes(). |
| switch (attribute.nodeName()) { |
| case AttributeNames::aria_actionsAttr: |
| case AttributeNames::aria_activedescendantAttr: |
| case AttributeNames::aria_controlsAttr: |
| case AttributeNames::aria_describedbyAttr: |
| case AttributeNames::aria_detailsAttr: |
| case AttributeNames::aria_errormessageAttr: |
| case AttributeNames::aria_flowtoAttr: |
| case AttributeNames::aria_labelledbyAttr: |
| case AttributeNames::aria_labeledbyAttr: |
| case AttributeNames::aria_ownsAttr: |
| case AttributeNames::commandforAttr: |
| case AttributeNames::headersAttr: |
| case AttributeNames::popovertargetAttr: |
| return true; |
| default: |
| return false; |
| } |
| } |
| |
| AXRelation AXObjectCache::symmetricRelation(AXRelation relation) |
| { |
| switch (relation) { |
| case AXRelation::Actions: |
| return AXRelation::ActionsOf; |
| case AXRelation::ActionsOf: |
| return AXRelation::Actions; |
| case AXRelation::ActiveDescendant: |
| return AXRelation::ActiveDescendantOf; |
| case AXRelation::ActiveDescendantOf: |
| return AXRelation::ActiveDescendant; |
| case AXRelation::ControlledBy: |
| return AXRelation::ControllerFor; |
| case AXRelation::ControllerFor: |
| return AXRelation::ControlledBy; |
| case AXRelation::DescribedBy: |
| return AXRelation::DescriptionFor; |
| case AXRelation::DescriptionFor: |
| return AXRelation::DescribedBy; |
| case AXRelation::Details: |
| return AXRelation::DetailsFor; |
| case AXRelation::DetailsFor: |
| return AXRelation::Details; |
| case AXRelation::ErrorMessage: |
| return AXRelation::ErrorMessageFor; |
| case AXRelation::ErrorMessageFor: |
| return AXRelation::ErrorMessage; |
| case AXRelation::FlowsFrom: |
| return AXRelation::FlowsTo; |
| case AXRelation::FlowsTo: |
| return AXRelation::FlowsFrom; |
| case AXRelation::Headers: |
| return AXRelation::HeaderFor; |
| case AXRelation::HeaderFor: |
| return AXRelation::Headers; |
| case AXRelation::LabeledBy: |
| return AXRelation::LabelFor; |
| case AXRelation::LabelFor: |
| return AXRelation::LabeledBy; |
| case AXRelation::NativeLabeledBy: |
| return AXRelation::NativeLabelFor; |
| case AXRelation::NativeLabelFor: |
| return AXRelation::NativeLabeledBy; |
| case AXRelation::OwnedBy: |
| return AXRelation::OwnerFor; |
| case AXRelation::OwnerFor: |
| return AXRelation::OwnedBy; |
| case AXRelation::None: |
| return AXRelation::None; |
| } |
| RELEASE_ASSERT_NOT_REACHED(); |
| } |
| |
| AXRelation AXObjectCache::attributeToRelationType(const QualifiedName& attribute) |
| { |
| if (attribute == aria_actionsAttr && m_document && m_document->settings().aRIAActionsSupportEnabled()) |
| return AXRelation::Actions; |
| if (attribute == aria_activedescendantAttr) |
| return AXRelation::ActiveDescendant; |
| if (attribute == aria_controlsAttr || attribute == commandforAttr || attribute == popovertargetAttr) |
| return AXRelation::ControllerFor; |
| if (attribute == aria_describedbyAttr) |
| return AXRelation::DescribedBy; |
| if (attribute == aria_detailsAttr) |
| return AXRelation::Details; |
| if (attribute == aria_errormessageAttr) |
| return AXRelation::ErrorMessage; |
| if (attribute == aria_flowtoAttr) |
| return AXRelation::FlowsTo; |
| if (attribute == aria_labelledbyAttr || attribute == aria_labeledbyAttr) |
| return AXRelation::LabeledBy; |
| if (attribute == aria_ownsAttr) |
| return AXRelation::OwnerFor; |
| if (attribute == headersAttr) |
| return AXRelation::Headers; |
| return AXRelation::None; |
| } |
| |
| static bool NODELETE validRelation(void* origin, void* target, AXRelation relation) |
| { |
| if (!origin || !target || relation == AXRelation::None) |
| return false; |
| return origin != target || relation == AXRelation::LabeledBy; |
| } |
| |
| static bool NODELETE validRelation(Element& origin, Element& target, AXRelation relation) |
| { |
| if (relation == AXRelation::None) |
| return false; |
| return &origin != &target || relation == AXRelation::LabeledBy; |
| } |
| |
| bool AXObjectCache::addRelation(Element& origin, Element& target, AXRelation relation) |
| { |
| AXTRACE("AXObjectCache::addRelation"_s); |
| AXLOG(makeString("origin: "_s, origin.debugDescription(), " target: "_s, target.debugDescription(), " relation "_s, static_cast<uint8_t>(relation))); |
| |
| if (!validRelation(origin, target, relation)) { |
| // Skip invalid relations (which can be created with legitimate markup, e.g. self-referential aria-owns. |
| return false; |
| } |
| |
| if (relation == AXRelation::LabelFor) { |
| // Add a LabelFor relation if the target doesn't have an ARIA label which should take precedence. |
| if (hasAnyARIALabelling(target)) |
| return false; |
| } |
| |
| return addRelation(RefPtr { getOrCreate(origin, IsPartOfRelation::Yes) }.get(), RefPtr { getOrCreate(target, IsPartOfRelation::Yes) }.get(), relation); |
| } |
| |
| static bool NODELETE canHaveRelations(Element& element) |
| { |
| auto elementName = element.elementName(); |
| return !(elementName == ElementName::HTML_meta || elementName == ElementName::HTML_head || elementName == ElementName::HTML_script || elementName == ElementName::HTML_html || elementName == ElementName::HTML_style); |
| } |
| |
| static bool relationCausesCycle(AccessibilityObject* origin, AccessibilityObject* target, AXRelation relation) |
| { |
| // Validate that we're not creating an aria-owns cycle. |
| // Cap the parent chain walk to avoid O(n * depth) behavior with adversarial aria-owns content. |
| // If we can't verify the relation is cycle-free within maxDepth, reject it. |
| static constexpr unsigned maxDepth = SettingsBase::defaultMaximumHTMLParserDOMTreeDepth * 3; |
| if (relation == AXRelation::OwnerFor) { |
| unsigned depth = 0; |
| for (auto* verifyOrigin = origin; verifyOrigin; verifyOrigin = verifyOrigin->parentObject(), ++depth) { |
| if (verifyOrigin == target || depth >= maxDepth) |
| return true; |
| } |
| } else if (relation == AXRelation::OwnedBy) { |
| unsigned depth = 0; |
| for (auto* verifyTarget = target; verifyTarget; verifyTarget = verifyTarget->parentObject(), ++depth) { |
| if (verifyTarget == origin || depth >= maxDepth) |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| bool AXObjectCache::addRelation(AccessibilityObject* origin, AccessibilityObject* target, AXRelation relation, AddSymmetricRelation addSymmetricRelation) |
| { |
| AXTRACE("AXObjectCache::addRelation"_s); |
| AXLOG(origin); |
| AXLOG(target); |
| AXLOG(relation); |
| |
| if (!validRelation(origin, target, relation)) |
| return false; |
| |
| if (relationCausesCycle(origin, target, relation)) |
| return false; |
| |
| if (relation == AXRelation::OwnerFor) { |
| // Before adding the new OwnerFor relationship, that alters the AX parent-child hierarchy, notify the current target-s parent that one child is being removed. |
| // FIXME: This kicks off children-changed notifications every time relations are dirtied and cleaned, |
| // even if this specific relationship existed before, incurring unnecessary work. |
| RefPtr targetParent = target->parentObject(); |
| if (targetParent && targetParent.get() != origin) |
| childrenChanged(targetParent.get()); |
| } |
| |
| AXID originID = origin->objectID(); |
| AXID targetID = target->objectID(); |
| auto relationsIterator = m_relations.find(originID); |
| if (relationsIterator == m_relations.end()) { |
| // No relations for this object, add the first one. |
| m_relations.add(originID, AXRelations { { std::to_underlying(relation), { targetID } } }); |
| } else if (auto targetsIterator = relationsIterator->value.find(std::to_underlying(relation)); targetsIterator == relationsIterator->value.end()) { |
| // No relation of this type for this object, add the first one. |
| relationsIterator->value.add(std::to_underlying(relation), ListHashSet { targetID }); |
| } else { |
| // There are already relations of this type for the object. Add the new relation. |
| if (relation == AXRelation::ActiveDescendant |
| || relation == AXRelation::OwnedBy) { |
| // There should be only one active descendant and only one owner. Enforce that by removing any existing targets. |
| targetsIterator->value.clear(); |
| } |
| targetsIterator->value.add(targetID); |
| } |
| m_relationTargets.add(targetID); |
| |
| if (relation == AXRelation::OwnerFor) { |
| m_hasAriaOwnsRelations = true; |
| // First find and clear the old owner. |
| for (auto oldOwnerIterator = m_relations.begin(); oldOwnerIterator != m_relations.end(); ++oldOwnerIterator) { |
| if (oldOwnerIterator->key == originID) |
| continue; |
| |
| removeRelationByID(oldOwnerIterator->key, targetID, AXRelation::OwnerFor); |
| } |
| |
| childrenChanged(origin); |
| } else if (relation == AXRelation::OwnedBy) { |
| if (RefPtr parentObject = origin->parentObjectUnignored()) |
| childrenChanged(parentObject.get()); |
| } |
| |
| if (addSymmetricRelation == AddSymmetricRelation::Yes |
| && m_objects.contains(originID) && m_objects.contains(targetID)) { |
| // If the IDs are still in the m_objects map, the objects should be still alive. |
| if (auto symmetric = symmetricRelation(relation); symmetric != AXRelation::None) |
| addRelation(target, origin, symmetric, AddSymmetricRelation::No); |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (auto tree = AXIsolatedTree::treeForFrameID(m_frameID)) { |
| if (origin && origin->isIgnored()) |
| deferAddUnconnectedNode(*origin); |
| if (target && target->isIgnored()) |
| deferAddUnconnectedNode(*target); |
| } |
| #endif |
| } |
| |
| return true; |
| } |
| |
| void AXObjectCache::removeAllRelations(AXID axID) |
| { |
| AXTRACE(makeString("AXObjectCache::removeRelations for axID "_s, axID.loggingString())); |
| |
| auto it = m_relations.find(axID); |
| if (it == m_relations.end()) |
| return; |
| |
| m_recentlyRemovedRelations.add(axID, it->value); |
| |
| for (auto relation : it->value.keys()) { |
| auto symmetric = symmetricRelation(static_cast<AXRelation>(relation)); |
| if (symmetric == AXRelation::None) |
| continue; |
| |
| auto targetIDs = it->value.get(static_cast<uint8_t>(relation)); |
| for (AXID targetID : targetIDs) |
| removeRelationByID(targetID, axID, symmetric); |
| } |
| |
| m_relations.remove(it); |
| dirtyIsolatedTreeRelations(); |
| } |
| |
| bool AXObjectCache::removeRelation(Element& origin, AXRelation relation) |
| { |
| AXTRACE(makeString("AXObjectCache::removeRelations for "_s, origin.debugDescription())); |
| AXLOG(relation); |
| |
| RefPtr object = get(&origin); |
| if (!object) |
| return false; |
| |
| auto relationsIterator = m_relations.find(object->objectID()); |
| if (relationsIterator == m_relations.end()) |
| return false; |
| |
| auto targetIDs = relationsIterator->value.take(std::to_underlying(relation)); |
| bool removedRelation = !targetIDs.isEmpty(); |
| |
| auto symmetric = symmetricRelation(relation); |
| if (symmetric != AXRelation::None) { |
| for (AXID targetID : targetIDs) |
| removeRelationByID(targetID, object->objectID(), symmetric); |
| } |
| |
| if (removedRelation && relation == AXRelation::OwnerFor) { |
| childrenChanged(object.get()); |
| // We also need to notify the object's natural parent it has changed children. |
| RefPtr node = object->node(); |
| if (RefPtr parentNode = node ? composedParentIgnoringDocumentFragments(*node) : nullptr) |
| childrenChanged(protect(get(*parentNode))); |
| else if (CheckedPtr renderer = object->renderer()) |
| childrenChanged(protect(get(renderer->parent()))); |
| } |
| |
| return removedRelation; |
| } |
| |
| void AXObjectCache::removeRelationByID(AXID originID, AXID targetID, AXRelation relation) |
| { |
| AXTRACE("AXObjectCache::removeRelationByID"_s); |
| AXLOG(makeString("originID "_s, originID.loggingString(), " targetID "_s, targetID.loggingString())); |
| AXLOG(relation); |
| |
| auto relationsIterator = m_relations.find(originID); |
| if (relationsIterator == m_relations.end()) |
| return; |
| |
| auto targetsIterator = relationsIterator->value.find(std::to_underlying(relation)); |
| if (targetsIterator == relationsIterator->value.end()) |
| return; |
| targetsIterator->value.remove(targetID); |
| } |
| |
| void AXObjectCache::updateRelationsIfNeeded() |
| { |
| if (!m_relationsNeedUpdate) |
| return; |
| |
| if (m_isRemovingNode) { |
| // Don't rebuild relations while removing a node (see remove(AXID)). Besides being crash-unsafe |
| // mid-destruction, reading the current (stale) relations here is correct: the parent ID that |
| // queueNodeRemoval() records must match the isolated tree's m_nodeMap, which reflects the same |
| // last-built relations. A fresh rebuild would desync from it and make removeSubtreeFromNodeMap() |
| // bail. m_relationsNeedUpdate stays set, so relations are rebuilt on the next update cycle. |
| // |
| // In the future, we should consider changing queueNodeRemoval()'s bail-if-parent-doesn't-match |
| // mechanism to something more robust. Presumably we can determine whether to bail purely based |
| // on whether the object is connected in the AX tree at all, catching the re-parenting scenario |
| // while avoiding the issues with our current mechanism (which can leak subtrees if we read the |
| // parent at the wrong time (the DOM has changed, relations have changed, etc). If we find a way |
| // to do that, we can probably remove this m_isRemovingNode flag. |
| return; |
| } |
| |
| relationsNeedUpdate(false); |
| m_relations.clear(); |
| m_recentlyRemovedRelations.clear(); |
| m_relationTargets.clear(); |
| m_unresolvedRelationTargetIds.clear(); |
| m_referencedRelationTargetIds.clear(); |
| m_hasAriaOwnsRelations = false; |
| |
| if (!m_doneInitialRelationsBuild) { |
| if (m_document) |
| updateRelationsForTree(m_document->rootNode()); |
| m_doneInitialRelationsBuild = true; |
| return; |
| } |
| |
| for (Ref element : m_elementsWithRelationAttributes) { |
| if (!canHaveRelations(element.get())) |
| continue; |
| for (const auto& attribute : relationAttributes()) |
| addRelation(element.get(), attribute); |
| addLabelForRelation(element.get()); |
| } |
| } |
| |
| void AXObjectCache::updateRelationsForTree(ContainerNode& rootNode) |
| { |
| AX_ASSERT(!rootNode.parentNode()); |
| for (Ref element : descendantsOfType<Element>(rootNode)) { |
| if (!canHaveRelations(element)) |
| continue; |
| |
| if (RefPtr shadowRoot = element->shadowRoot(); shadowRoot && shadowRoot->mode() != ShadowRootMode::UserAgent) |
| updateRelationsForTree(*shadowRoot); |
| if (auto* frameOwnerElement = dynamicDowncast<HTMLFrameOwnerElement>(element.get())) { |
| if (RefPtr document = frameOwnerElement->contentDocument()) |
| updateRelationsForTree(*document); |
| } |
| |
| bool hasRelationAttribute = false; |
| for (const auto& attribute : relationAttributes()) { |
| if (addRelation(element, attribute) || !element->attributeWithoutSynchronization(attribute).isNull()) { |
| // Track elements even when addRelation fails to resolve a target, since the target may appear later via an id change. |
| hasRelationAttribute = true; |
| } |
| } |
| |
| // In addition to ARIA specified relations, there may be other relevant relations. |
| // For instance, LabelFor in HTMLLabelElements. |
| addLabelForRelation(element); |
| |
| if (hasRelationAttribute || is<HTMLLabelElement>(element.get())) |
| m_elementsWithRelationAttributes.add(element); |
| } |
| } |
| |
| void AXObjectCache::trackRelationAttributeElement(Element& element) |
| { |
| // This runs synchronously during DOM mutation and parsing, so it must not |
| // resolve relations or touch layout. |
| if (!canHaveRelations(element)) |
| return; |
| m_elementsWithRelationAttributes.add(element); |
| |
| if (!m_relationsNeedUpdate) |
| relationsNeedUpdate(true); |
| } |
| |
| bool AXObjectCache::idChangeCanAffectRelations(Element* element, const AtomString& oldID, const AtomString& newID) const |
| { |
| auto isReferenced = [&](const AtomString& id) { |
| return !id.isEmpty() && m_referencedRelationTargetIds.contains(id); |
| }; |
| if (isReferenced(oldID) || isReferenced(newID)) { |
| // An id change affects relations only if the old or new id is referenced by a relation attribute. |
| return true; |
| } |
| |
| // A relation can also resolve through a shadow root's reference target, in which case it depends on |
| // an inner id (the reference target) rather than the relation attribute's value, so that inner id is |
| // not in m_referencedRelationTargetIds. Conservatively re-resolve when an id changes inside a shadow |
| // tree that uses a reference target. |
| RefPtr shadowRoot = element ? element->containingShadowRoot() : nullptr; |
| return shadowRoot && shadowRoot->hasReferenceTarget(); |
| } |
| |
| bool AXObjectCache::addRelation(Element& origin, const QualifiedName& attribute) |
| { |
| if (attribute == aria_labeledbyAttr && origin.hasAttribute(aria_labelledbyAttr)) { |
| // The attribute name with British spelling should override the one with American spelling. |
| return false; |
| } |
| |
| bool addedRelation = false; |
| auto relation = attributeToRelationType(attribute); |
| if (!m_document) |
| return false; |
| |
| // Remember any referenced ids whose target doesn't exist yet, so that if an element with one of |
| // these ids is inserted later, we know to dirty relations and re-resolve it. Also remember every |
| // referenced id (resolved or not) so that an id-attribute change can be cheaply checked against it. |
| if (const auto& value = origin.attributeWithoutSynchronization(attribute); !value.isNull()) { |
| Ref treeScope = origin.treeScope(); |
| for (auto& id : SpaceSplitString(value, SpaceSplitString::ShouldFoldCase::No)) { |
| m_referencedRelationTargetIds.add(id); |
| if (!treeScope->elementByIdResolvingReferenceTarget(id)) |
| m_unresolvedRelationTargetIds.add(id); |
| } |
| } |
| |
| if (!origin.isInTreeScope()) { |
| // When an origin is not in a tree scope, Element::elementsArrayForAttributeInternal() can't use the |
| // TreeScope id map and falls back to getElementByIdIncludingDisconnected(), which linearly scans |
| // the entire detached subtree once per referenced id. On pages with large detached subtrees this |
| // can cause performance issues. |
| return false; |
| } |
| |
| if (Element::isElementReflectionAttribute(m_document->settings(), attribute)) { |
| if (auto reflectedElement = origin.elementForAttributeInternal(attribute)) |
| return addRelation(origin, *reflectedElement, relation); |
| } else if (Element::isElementsArrayReflectionAttribute(attribute)) { |
| if (auto reflectedElements = origin.elementsArrayForAttributeInternal(attribute)) { |
| for (auto reflectedElement : reflectedElements.value()) { |
| if (addRelation(origin, reflectedElement, relation)) |
| addedRelation = true; |
| } |
| return addedRelation; |
| } |
| } |
| |
| auto& value = origin.attributeWithoutSynchronization(attribute); |
| if (value.isNull()) { |
| if (CheckedPtr defaultARIA = origin.customElementDefaultARIAIfExists()) { |
| if (std::optional elements = defaultARIA->elementsForAttribute(origin, attribute)) { |
| for (auto& target : *elements) { |
| if (addRelation(origin, target, relation)) |
| addedRelation = true; |
| } |
| } |
| } |
| return addedRelation; |
| } |
| |
| SpaceSplitString ids(value, SpaceSplitString::ShouldFoldCase::No); |
| for (auto& id : ids) { |
| RefPtr target = protect(origin.treeScope())->elementByIdResolvingReferenceTarget(id); |
| if (!target || target == &origin) |
| continue; |
| |
| if (addRelation(origin, *target, relation)) |
| addedRelation = true; |
| } |
| |
| return addedRelation; |
| } |
| |
| void AXObjectCache::addLabelForRelation(Element& origin) |
| { |
| RefPtr label = dynamicDowncast<HTMLLabelElement>(origin); |
| |
| if (label) { |
| if (const auto& controlID = label->attributeWithoutSynchronization(forAttr); !controlID.isEmpty()) |
| m_referencedRelationTargetIds.add(controlID); |
| } |
| |
| // A detached label has no accessibility object, so its label relations have no consumer. Skipping |
| // it here also avoids HTMLLabelElement::control()'s scan of the label's descendants. |
| if (!origin.isInTreeScope()) |
| return; |
| |
| bool addedRelation = false; |
| |
| // LabelFor relations are established for <label for=...>. |
| if (label) { |
| if (RefPtr control = Accessibility::controlForLabelElement(*label)) { |
| // Always add NativeLabelFor for geometry purposes. |
| addedRelation = addRelation(origin, *control, AXRelation::NativeLabelFor); |
| // Only add LabelFor (for accname) if no ARIA labelling exists. |
| if (!hasAnyARIALabelling(*control)) |
| addRelation(origin, *control, AXRelation::LabelFor); |
| } |
| } |
| |
| if (addedRelation) |
| dirtyIsolatedTreeRelations(); |
| } |
| |
| void AXObjectCache::updateRelations(Element& origin, const QualifiedName& attribute) |
| { |
| if (!canHaveRelations(origin)) |
| return; |
| |
| auto relation = attributeToRelationType(attribute); |
| if (relation == AXRelation::None) { |
| AX_ASSERT_NOT_REACHED(); |
| return; |
| } |
| |
| // `commandfor` is only valid for button elements. |
| if (attribute == commandforAttr) [[unlikely]] { |
| if (!is<HTMLButtonElement>(origin)) |
| return; |
| } |
| |
| // `popovertarget` is only valid for input and button elements. |
| if (attribute == popovertargetAttr) [[unlikely]] { |
| if (!is<HTMLInputElement>(origin) && !is<HTMLButtonElement>(origin)) |
| return; |
| } |
| |
| bool changedRelation = removeRelation(origin, relation); |
| changedRelation |= addRelation(origin, attribute); |
| if (changedRelation) |
| dirtyIsolatedTreeRelations(); |
| } |
| |
| void AXObjectCache::relationsNeedUpdate(bool needUpdate) |
| { |
| m_relationsNeedUpdate = needUpdate; |
| if (m_relationsNeedUpdate) |
| dirtyIsolatedTreeRelations(); |
| } |
| |
| HashMap<AXID, AXRelations> AXObjectCache::relations() |
| { |
| updateRelationsIfNeeded(); |
| return m_relations; |
| } |
| |
| const HashSet<AXID>& AXObjectCache::relationTargetIDs() |
| { |
| updateRelationsIfNeeded(); |
| return m_relationTargets; |
| } |
| |
| bool AXObjectCache::isDescendantOfRelatedNode(Node& node) |
| { |
| auto& targetIDs = relationTargetIDs(); |
| for (RefPtr parent = node.parentNode(); parent; parent = parent->parentNode()) { |
| RefPtr object = get(*parent); |
| if (object && (m_relations.contains(object->objectID()) || targetIDs.contains(object->objectID()))) |
| return true; |
| } |
| return false; |
| } |
| |
| std::optional<ListHashSet<AXID>> AXObjectCache::relatedObjectIDsFor(const AXCoreObject& object, AXRelation relation, UpdateRelations updateRelations) |
| { |
| if (updateRelations == UpdateRelations::Yes) |
| updateRelationsIfNeeded(); |
| |
| auto relationsIterator = m_relations.find(object.objectID()); |
| if (relationsIterator == m_relations.end()) |
| return std::nullopt; |
| |
| auto targetsIterator = relationsIterator->value.find(std::to_underlying(relation)); |
| if (targetsIterator == relationsIterator->value.end()) |
| return std::nullopt; |
| return targetsIterator->value; |
| } |
| |
| #if PLATFORM(COCOA) |
| void AXObjectCache::announce(const String& message) |
| { |
| postPlatformAnnouncementNotification(message); |
| } |
| #else |
| void AXObjectCache::announce(const String&) |
| { |
| // FIXME: implement in other platforms. |
| } |
| #endif |
| |
| #if !PLATFORM(COCOA) && !USE(ATSPI) |
| AXTextChange AXObjectCache::textChangeForEditType(AXTextEditType type) |
| { |
| switch (type) { |
| case AXTextEditType::Cut: |
| case AXTextEditType::Delete: |
| return AXTextChange::Deleted; |
| case AXTextEditType::Insert: |
| case AXTextEditType::Dictation: |
| case AXTextEditType::Typing: |
| case AXTextEditType::Paste: |
| return AXTextChange::Inserted; |
| case AXTextEditType::Replace: |
| return AXTextChange::Replaced; |
| case AXTextEditType::AttributesChange: |
| return AXTextChange::AttributesChanged; |
| case AXTextEditType::Unknown: |
| AX_ASSERT_NOT_REACHED(); |
| return AXTextChange::Inserted; |
| } |
| } |
| #endif |
| |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| void AXObjectCache::selectedTextRangeTimerFired() |
| { |
| if (!accessibilityEnabled()) |
| return; |
| |
| m_selectedTextRangeTimer.stop(); |
| |
| if (m_lastDebouncedTextRangeObject) { |
| for (RefPtr axObject = objectForID(*m_lastDebouncedTextRangeObject); axObject; axObject = axObject->parentObject()) { |
| if (axObject->isTextControl()) |
| postNotification(*axObject, AXNotification::SelectedTextChanged); |
| } |
| } |
| |
| m_lastDebouncedTextRangeObject = std::nullopt; |
| } |
| |
| void AXObjectCache::processQueuedIsolatedNodeUpdates() |
| { |
| if (auto tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->processQueuedNodeUpdates(); |
| } |
| #endif |
| |
| void AXObjectCache::onWidgetVisibilityChanged(RenderWidget& widget) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| postNotification(get(widget), AXNotification::VisibilityChanged); |
| #else |
| UNUSED_PARAM(widget); |
| #endif |
| } |
| |
| #if PLATFORM(MAC) |
| bool AXObjectCache::isAppleInternalInstall() |
| { |
| static bool isInternal = os_variant_allows_internal_security_policies("com.apple.Accessibility"); |
| return isInternal; |
| } |
| #endif // PLATFORM(COCOA) |
| |
| void AXObjectCache::objectBecameIgnored(const AccessibilityObject& object) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->objectBecameIgnored(object); |
| #endif |
| #if ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| if (object.isFrame()) { |
| if (RefPtr scrollView = dynamicDowncast<AccessibilityScrollView>(object.parentObject()); scrollView && scrollView->role() == AccessibilityRole::FrameHost) |
| scrollView->updateHostedFrameInheritedState(); |
| } |
| #endif |
| #if !ENABLE(ACCESSIBILITY_ISOLATED_TREE) && !ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| UNUSED_PARAM(object); |
| #endif |
| } |
| |
| void AXObjectCache::objectBecameUnignored(const AccessibilityObject& object) |
| { |
| #if ENABLE(ACCESSIBILITY_ISOLATED_TREE) |
| if (RefPtr tree = AXIsolatedTree::treeForFrameID(m_frameID)) |
| tree->objectBecameUnignored(object); |
| #endif |
| #if ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| if (object.isFrame()) { |
| if (RefPtr scrollView = dynamicDowncast<AccessibilityScrollView>(object.parentObject()); scrollView && scrollView->role() == AccessibilityRole::FrameHost) |
| scrollView->updateHostedFrameInheritedState(); |
| } |
| #endif |
| #if !ENABLE(ACCESSIBILITY_ISOLATED_TREE) && !ENABLE(ACCESSIBILITY_LOCAL_FRAME) |
| UNUSED_PARAM(object); |
| #endif |
| } |
| |
| } // namespace WebCore |