blob: 5abab4809be8a2e9d95a3a0f02f8e7c136a16e93 [file]
/*
* Copyright (C) 2024-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.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
#include "config.h"
#include "AXSearchManager.h"
#include "AccessibilityObjectInlines.h"
#include "AXLogger.h"
#include "AXLoggerBase.h"
#include "AXObjectCacheInlines.h"
#include "AXRemoteFrame.h"
#include "AXTreeStoreInlines.h"
#include "AXUtilities.h"
#include "AccessibilityObject.h"
#include "AccessibilityScrollView.h"
#include "FrameDestructionObserverInlines.h"
#include "LocalFrameInlines.h"
#include "LocalFrameView.h"
#include "Logging.h"
#include "TextIterator.h"
#include <ranges>
namespace WebCore {
DEFINE_ALLOCATOR_WITH_HEAP_IDENTIFIER(AXSearchManager);
// This function determines if the given `axObject` is a radio button part of a different ad-hoc radio group
// than `referenceObject`, where ad-hoc radio group membership is determined by comparing `name` attributes.
static bool isRadioButtonInDifferentAdhocGroup(Ref<AXCoreObject> axObject, AXCoreObject* referenceObject)
{
if (!axObject->isRadioButton())
return false;
// If the `referenceObject` is not a radio button and this `axObject` is, their radio group membership is different because
// `axObject` belongs to a group and `referenceObject` doesn't.
if (!referenceObject || !referenceObject->isRadioButton())
return true;
return axObject->nameAttribute() != referenceObject->nameAttribute();
}
bool AXSearchManager::matchForSearchKeyAtIndex(Ref<AXCoreObject> axObject, const AccessibilitySearchCriteria& criteria, size_t index)
{
RefPtr startObject = criteria.startObject.get();
switch (criteria.searchKeys[index]) {
case AccessibilitySearchKey::AnyType:
// The AccessibilitySearchKey::AnyType matches any non-null AccessibilityObject.
return true;
case AccessibilitySearchKey::Article:
return axObject->role() == AccessibilityRole::DocumentArticle;
case AccessibilitySearchKey::BlockquoteSameLevel:
return startObject
&& axObject->isBlockquote()
&& axObject->blockquoteLevel() == startObject->blockquoteLevel();
case AccessibilitySearchKey::Blockquote:
return axObject->isBlockquote();
case AccessibilitySearchKey::BoldFont:
return axObject->hasBoldFont();
case AccessibilitySearchKey::Button:
return axObject->isButton();
case AccessibilitySearchKey::Checkbox:
return axObject->isCheckbox();
case AccessibilitySearchKey::Control:
return axObject->isControl() || axObject->isSummary();
case AccessibilitySearchKey::DifferentType:
return startObject
&& axObject->role() != startObject->role();
case AccessibilitySearchKey::FontChange:
return startObject
&& !axObject->hasSameFont(*startObject);
case AccessibilitySearchKey::FontColorChange:
return startObject
&& !axObject->hasSameFontColor(*startObject);
case AccessibilitySearchKey::Frame:
return axObject->isWebArea();
case AccessibilitySearchKey::Graphic:
return axObject->isImage() && !axObject->isInImage();
case AccessibilitySearchKey::HeadingLevel1:
return axObject->headingLevel() == 1;
case AccessibilitySearchKey::HeadingLevel2:
return axObject->headingLevel() == 2;
case AccessibilitySearchKey::HeadingLevel3:
return axObject->headingLevel() == 3;
case AccessibilitySearchKey::HeadingLevel4:
return axObject->headingLevel() == 4;
case AccessibilitySearchKey::HeadingLevel5:
return axObject->headingLevel() == 5;
case AccessibilitySearchKey::HeadingLevel6:
return axObject->headingLevel() == 6;
case AccessibilitySearchKey::HeadingSameLevel:
return startObject
&& axObject->isHeading()
&& axObject->headingLevel() == startObject->headingLevel();
case AccessibilitySearchKey::Heading:
return axObject->isHeading();
case AccessibilitySearchKey::Highlighted:
return axObject->hasHighlighting();
case AccessibilitySearchKey::KeyboardFocusable:
return axObject->isKeyboardFocusable();
case AccessibilitySearchKey::ItalicFont:
return axObject->hasItalicFont();
case AccessibilitySearchKey::Landmark:
return axObject->isLandmark();
case AccessibilitySearchKey::Link: {
bool isLink = axObject->isLink();
#if PLATFORM(IOS_FAMILY)
if (!isLink)
isLink = axObject->isDescendantOfRole(AccessibilityRole::Link);
#endif
return isLink;
}
case AccessibilitySearchKey::List:
return axObject->isList();
case AccessibilitySearchKey::LiveRegion:
return axObject->supportsLiveRegion();
case AccessibilitySearchKey::MisspelledWord: {
auto ranges = axObject->misspellingRanges();
bool hasMisspelling = !ranges.isEmpty();
if (hasMisspelling)
m_misspellingRanges.set(axObject->objectID(), WTF::move(ranges));
return hasMisspelling;
}
case AccessibilitySearchKey::Outline:
return axObject->isTree();
case AccessibilitySearchKey::PlainText:
return axObject->hasPlainText();
case AccessibilitySearchKey::RadioGroup:
return axObject->isRadioGroup() || isRadioButtonInDifferentAdhocGroup(axObject, startObject.get());
case AccessibilitySearchKey::SameType:
return startObject
&& axObject->role() == startObject->role();
case AccessibilitySearchKey::StaticText:
return axObject->isStaticText();
case AccessibilitySearchKey::StyleChange:
return startObject
&& !axObject->hasSameStyle(*startObject);
case AccessibilitySearchKey::TableSameLevel:
return startObject
&& axObject->isExposableTable()
&& axObject->tableLevel() == startObject->tableLevel();
case AccessibilitySearchKey::Table:
return axObject->isExposableTable();
case AccessibilitySearchKey::TextField:
return axObject->isTextControl();
case AccessibilitySearchKey::Underline:
return axObject->hasUnderline();
case AccessibilitySearchKey::UnvisitedLink:
return axObject->isUnvisitedLink();
case AccessibilitySearchKey::VisitedLink:
return axObject->isVisitedLink();
default:
return false;
}
}
bool AXSearchManager::match(Ref<AXCoreObject> axObject, const AccessibilitySearchCriteria& criteria)
{
for (size_t i = 0; i < criteria.searchKeys.size(); ++i) {
if (matchForSearchKeyAtIndex(axObject, criteria, i))
return criteria.visibleOnly ? axObject->isOnScreen() : true;
}
return false;
}
bool AXSearchManager::matchText(Ref<AXCoreObject> axObject, const String& searchText)
{
// If text is empty we return true.
if (searchText.isEmpty())
return true;
return containsPlainText(axObject->title(), searchText, FindOption::CaseInsensitive)
|| containsPlainText(axObject->description(), searchText, FindOption::CaseInsensitive)
|| containsPlainText(axObject->stringValue(), searchText, FindOption::CaseInsensitive);
}
static void appendAccessibilityObject(Ref<AXCoreObject> object, AccessibilityObject::AccessibilityChildrenVector& results)
{
if (!object->isAttachment()) [[likely]]
results.append(WTF::move(object));
else if (RefPtr axObject = dynamicDowncast<AccessibilityObject>(object)) {
// Find the next descendant of this attachment object so search can continue through frames.
RefPtr widget = axObject->widgetForAttachmentView();
#if ENABLE(ACCESSIBILITY_LOCAL_FRAME)
if (widget && widget->isRemoteFrameView()) {
// For an out-of-process (site-isolated) iframe, the content lives in another process, so we can't
// descend into it. Append the AXRemoteFrame placeholder instead so the search records the iframe in
// tree order; the client descends into it via the placeholder's platform element (AXRemoteElement).
CheckedPtr cache = axObject->axObjectCache();
if (RefPtr remoteFrameHost = cache ? cache->getOrCreate(*widget) : nullptr) {
remoteFrameHost->updateChildrenIfNecessary();
if (RefPtr scrollView = dynamicDowncast<AccessibilityScrollView>(*remoteFrameHost)) {
if (RefPtr remoteFrame = scrollView->remoteFrame())
results.append(remoteFrame.releaseNonNull());
}
}
return;
}
#endif // ENABLE(ACCESSIBILITY_LOCAL_FRAME)
RefPtr frameView = dynamicDowncast<LocalFrameView>(widget);
if (!frameView)
return;
RefPtr document = frameView->frame().document();
if (!document || document->renderTreeState() != Document::RenderTreeState::Built)
return;
CheckedPtr cache = axObject->axObjectCache();
if (RefPtr axDocument = cache ? cache->getOrCreate(*document) : nullptr)
results.append(axDocument.releaseNonNull());
}
}
static void appendChildrenToArray(AXCoreObject& object, bool isForward, RefPtr<AXCoreObject> startObject, AXCoreObject::AccessibilityChildrenVector& results)
{
// A table's children includes elements whose own children are also the table's children (due to the way the Mac exposes tables).
// The rows from the table should be queried, since those are direct descendants of the table, and they contain content.
// FIXME: Unlike AXCoreObject::children(), AXCoreObject::rows() returns a copy, not a const-reference. This can be wasteful
// for tables with lots of rows and probably should be changed.
const auto& searchChildren = object.isExposableTable() ? object.rows() : object.crossFrameUnignoredChildren();
size_t childrenSize = searchChildren.size();
size_t startIndex = isForward ? childrenSize : 0;
size_t endIndex = isForward ? 0 : childrenSize;
// Save the original startObject before the ignored-element handling may
// modify or nullify it. We need the original for the descendant-lookup
// fallback below.
RefPtr<AXCoreObject> originalStartObject = startObject;
// If the startObject is ignored, we should use an accessible sibling as a start element instead.
if (startObject && startObject->isIgnored() && startObject->crossFrameIsDescendantOfObject(object)) {
RefPtr<AXCoreObject> parentObject = startObject->parentObjectIncludingCrossFrame();
// Go up the parent chain to find the highest ancestor that's also being ignored.
while (parentObject && parentObject->isIgnored()) {
if (parentObject == &object)
break;
startObject = parentObject;
parentObject = parentObject->parentObjectIncludingCrossFrame();
}
// Get the un-ignored sibling based on the search direction, and update the searchPosition.
if (startObject->isIgnored())
startObject = isForward ? startObject->previousSiblingUnignored() : startObject->nextSiblingUnignored();
}
size_t searchPosition = notFound;
if (startObject) {
searchPosition = searchChildren.findIf([&](const Ref<AXCoreObject>& object) {
return startObject == object.ptr();
});
}
// If startObject wasn't found directly in children, it may be an ignored
// ancestor of one of the children. For example, an iframe's FrameHost
// (AccessibilityScrollView) is ignored, but its child RemoteFrame appears
// directly in the parent's unignored children. Since crossFrameUnignoredChildren()
// replaces ignored parents with their children, we can find the right position
// by looking up the ignored element's direct children in searchChildren.
if (searchPosition == notFound && originalStartObject) {
for (const auto& child : originalStartObject->children()) {
searchPosition = searchChildren.findIf([&](const Ref<AXCoreObject>& searchChild) {
return searchChild.ptr() == child.ptr();
});
if (searchPosition != notFound)
break;
}
}
if (searchPosition != notFound) {
if (isForward)
endIndex = searchPosition + 1;
else
endIndex = searchPosition;
}
// This is broken into two statements so that it's easier read.
if (isForward) {
for (size_t i = startIndex; i > endIndex; i--)
appendAccessibilityObject(searchChildren.at(i - 1), results);
} else {
for (size_t i = startIndex; i < endIndex; i++)
appendAccessibilityObject(searchChildren.at(i), results);
}
}
DidTimeout AXSearchManager::revealHiddenMatchWithTimeout(AXCoreObject& matchedObject, Seconds timeout)
{
auto revealAndUpdateAccessibilityTrees = [axID = matchedObject.objectID(), treeID = matchedObject.treeID()] {
WeakPtr cache = AXTreeStore<AXObjectCache>::axObjectCacheForID(treeID);
RefPtr object = cache ? cache->objectForID(axID) : nullptr;
if (!object)
return;
object->revealAncestors();
for (RefPtr ancestor = object; ancestor; ancestor = downcast<AccessibilityObject>(ancestor->parentObjectIncludingCrossFrame())) {
if (RefPtr document = ancestor->document(); document && needsLayoutOrStyleRecalc(*document)) {
document->updateLayoutIgnorePendingStylesheets();
#if ENABLE(ACCESSIBILITY_ISOLATED_TREE)
cache->scheduleObjectRegionsUpdate(/* scheduleImmediately */ true);
#endif
}
ancestor->recomputeIsIgnored();
}
cache->performDeferredCacheUpdate(ForceLayout::Yes);
#if ENABLE(ACCESSIBILITY_ISOLATED_TREE)
if (RefPtr tree = AXTreeStore<AXIsolatedTree>::isolatedTreeForID(treeID))
tree->processQueuedNodeUpdates();
#endif
};
if (lastRevealAttemptTimedOut()) {
// If the last reveal attempt timed out because the main-thread is busy, don't delay this search any further.
// We should still expand the collapsed content to increase the chance the user discovers it later when the
// main-thread has stopped being busy and can perform the expansion.
Accessibility::performFunctionOnMainThread(revealAndUpdateAccessibilityTrees);
return DidTimeout::Yes;
}
auto didTimeout = Accessibility::performFunctionOnMainThreadAndWaitWithTimeout(revealAndUpdateAccessibilityTrees, timeout);
if (didTimeout == DidTimeout::Yes)
setLastRevealAttemptTimedOut(true);
return didTimeout;
}
AccessibilitySearchResultStream AXSearchManager::findMatchingObjectsAsStream(AccessibilitySearchCriteria&& criteria, RemoteFrameSearchCallback&& remoteFrameCallback)
{
return findMatchingObjectsInternalAsStream(criteria, remoteFrameCallback);
}
AccessibilitySearchResultStream AXSearchManager::findMatchingObjectsInternalAsStream(const AccessibilitySearchCriteria& criteria, const RemoteFrameSearchCallback& remoteFrameCallback)
{
AXTRACE("AXSearchManager::findMatchingObjectsInternalAsStream"_s);
AXLOG(criteria);
AccessibilitySearchResultStream stream;
stream.setResultsLimit(criteria.resultsLimit);
if (!criteria.searchKeys.size())
return stream;
RefPtr anchorObject = criteria.anchorObject.get();
if (!anchorObject)
return stream;
if (anchorObject->isIgnored() && criteria.immediateDescendantsOnly) {
// If the anchor is ignored (e.g. a FrameHost for a cross-origin iframe with
// ENABLE_ACCESSIBILITY_LOCAL_FRAME), it's not really in the outwardly exposed
// accessibility tree, and thus doesn't have any true immediate descendants.
// Allowing these searches would cause issues for ATs, e.g. infinite navigation
// loops in VoiceOver.
return stream;
}
// Track how many local results we've found to determine when to stop searching.
unsigned localResultCount = 0;
bool shouldCheckForRevealableText = !criteria.visibleOnly && !criteria.immediateDescendantsOnly && !criteria.searchText.isEmpty();
auto matchWithinRevealableContainer = [&] (AXCoreObject& object) -> bool {
if (!shouldCheckForRevealableText)
return false;
for (const auto& revealableContainer : object.revealableContainers()) {
RefPtr descendant = revealableContainer.get();
while ((descendant = descendant ? descendant->nextInPreOrder(/* updateChildren */ true, /* stayWithin */ revealableContainer.ptr(), /* crossFrame */ true) : nullptr)) {
if (match(*descendant, criteria) && containsPlainText(descendant->revealableText(), criteria.searchText, FindOption::CaseInsensitive)) {
if (revealHiddenMatchWithTimeout(*descendant, 100_ms) == DidTimeout::No) {
stream.appendLocalResult(*descendant);
++localResultCount;
return true;
}
}
}
}
return false;
};
auto addMatchToStream = [&](Ref<AXCoreObject> matchObject) -> bool {
if (match(matchObject, criteria) && matchText(matchObject, criteria.searchText)) {
stream.appendLocalResult(matchObject);
++localResultCount;
return localResultCount >= criteria.resultsLimit;
}
return false;
};
// This search algorithm only searches the elements before/after the starting object.
// It does this by stepping up the parent chain and at each level doing a DFS.
// If there's no start object, it means we want to search everything.
RefPtr startObject = criteria.startObject.get();
// Whether the caller passed an explicit start, as opposed to startObject being defaulted to the anchor below.
bool hasExplicitStartObject = startObject;
if (!startObject)
startObject = anchorObject;
bool isForward = criteria.searchDirection == AccessibilitySearchDirection::Next;
#if PLATFORM(COCOA)
// For backward search starting from a remote frame, we need to handle that frame first so its
// content isn't skipped (otherwise the backward search would only see elements before it in the parent).
if (!isForward && startObject != anchorObject && startObject->isRemoteFrame()) {
if (std::optional frameID = startObject->remoteFrameID(); frameID && startObject->remoteFramePID()) {
#if PLATFORM(IOS_FAMILY)
// iOS returns the remote frame's AXRemoteElement placeholder inline; VoiceOver descends into it.
stream.appendRemoteFrame(*frameID, startObject);
#else
// macOS forwards the search into the remote frame via IPC.
stream.appendRemoteFrame(*frameID);
if (remoteFrameCallback)
remoteFrameCallback(*frameID, stream.entryCount(), localResultCount);
#endif
}
}
#endif // PLATFORM(COCOA)
// Handle the case where the startObject we received is ignored, because the search algorithm only
// walks the unignored tree. This happens when the start becomes ignored after a dynamic update,
// for example in response to a focus handler the assistive technology triggered by moving onto a
// new element. Resolve the positioning start to the nearest unignored element in the resume direction:
// the unignored element just before the start for a forward search, just after it for a backward search.
//
// criteria.startObject is deliberately left unchanged, so relative search keys (SameType, DifferentType,
// HeadingSameLevel, BlockquoteSameLevel, TableSameLevel, FontChange, FontColorChange, StyleChange) still
// compare candidates against the original start. Once the positioning start is resolved, the normal
// parent-walk below handles everything.
if (startObject && startObject != anchorObject && startObject->isIgnored()) {
// The neighbor is almost always a few steps away; the bound just guards against a pathologically
// large run of ignored elements rather than walking the whole document.
constexpr unsigned ignoredStartResolveStepLimit = 250;
RefPtr<AXCoreObject> resolved = startObject;
for (unsigned step = 0; step < ignoredStartResolveStepLimit; ++step) {
resolved = isForward
? resolved->previousInPreOrder(/* updateChildrenIfNeeded */ true, anchorObject.get(), /* includeCrossFrame */ true)
: resolved->nextInPreOrder(/* updateChildrenIfNeeded */ true, anchorObject.get(), /* includeCrossFrame */ true);
if (!resolved || !resolved->isIgnored())
break;
}
if (resolved && !resolved->isIgnored())
startObject = resolved;
}
// The first iteration of the outer loop will examine the children of the start object for matches. However, when
// iterating backwards, the start object children should not be considered, so the loop is skipped ahead. We make an
// exception when no start object was specified because we want to search everything regardless of search direction.
RefPtr<AXCoreObject> previousObject;
if (!isForward && hasExplicitStartObject) {
previousObject = startObject;
startObject = startObject->crossFrameParentObjectUnignored();
}
if (startObject && matchWithinRevealableContainer(*startObject) && localResultCount >= criteria.resultsLimit)
return stream;
// The outer loop steps up the parent chain each time (unignored is important here because otherwise elements would be searched twice)
for (RefPtr stopSearchElement = anchorObject->crossFrameParentObjectUnignored(); startObject && startObject != stopSearchElement; startObject = startObject->crossFrameParentObjectUnignored()) {
// Only append the children after/before the previous element, so that the search does not check elements that are
// already behind/ahead of start element.
AXCoreObject::AccessibilityChildrenVector searchStack;
if (!criteria.immediateDescendantsOnly || startObject == anchorObject)
appendChildrenToArray(*startObject, isForward, previousObject, searchStack);
// This now does a DFS at the current level of the parent.
while (!searchStack.isEmpty()) {
Ref searchObject = searchStack.takeLast();
#if PLATFORM(COCOA)
// Check if this is a remote frame - if so, record it in the stream to maintain tree order.
if (searchObject->isRemoteFrame()) {
std::optional frameID = searchObject->remoteFrameID();
#if PLATFORM(IOS_FAMILY)
// iOS returns the remote frame's AXRemoteElement placeholder inline and lets VoiceOver
// descend into it; there's no in-WebProcess cross-process coordination like on macOS.
if (frameID && searchObject->remoteFramePID())
stream.appendRemoteFrame(*frameID, RefPtr { searchObject.ptr() });
UNUSED_PARAM(remoteFrameCallback);
#else // !PLATFORM(IOS_FAMILY)
// macOS forwards the search into the remote frame via IPC.
if (frameID && searchObject->remoteFramePID()) {
stream.appendRemoteFrame(*frameID);
// Invoke callback to allow eager IPC dispatch while search continues.
if (remoteFrameCallback)
remoteFrameCallback(*frameID, stream.entryCount(), localResultCount);
}
#endif // PLATFORM(IOS_FAMILY)
// Don't descend into remote frames - we'll forward the search to them
// via IPC in |remoteFrameCallback|.
continue;
}
#else // !PLATFORM(COCOA)
UNUSED_PARAM(remoteFrameCallback);
#endif // PLATFORM(COCOA)
if (addMatchToStream(searchObject))
break;
if (matchWithinRevealableContainer(searchObject.get()) && localResultCount >= criteria.resultsLimit)
break;
if (!criteria.immediateDescendantsOnly)
appendChildrenToArray(searchObject, isForward, nullptr, searchStack);
}
if (localResultCount >= criteria.resultsLimit)
break;
// When moving backwards, the parent object needs to be checked, because technically it's "before" the starting element.
// Only an object within the anchor is eligible. A search is scoped to its receiver, so if the start element lies outside
// the anchor's subtree this walk begins above the anchor, never reaches the stop element below it, and would otherwise
// match the anchor's own ancestors. That hands the client an element it is already above, which looks like navigation
// escaping the page: walking up out of an iframe returned the main frame's root scroll area.
if (!isForward && startObject != anchorObject && startObject->crossFrameIsDescendantOfObject(*anchorObject)
&& addMatchToStream(*startObject))
break;
previousObject = startObject;
}
AXLOG(makeString("Stream total entries count: %zu. Local result count: %u"_s, stream.entryCount(), localResultCount));
return stream;
}
std::optional<AXTextMarkerRange> AXSearchManager::findMatchingRange(AccessibilitySearchCriteria&& criteria)
{
AXTRACE("AXSearchManager::findMatchingRange"_s);
// Currently, this method only supports searching for the next/previous misspelling.
// FIXME: support other types of ranges, like italicized.
if (criteria.searchKeys.size() != 1 || criteria.searchKeys[0] != AccessibilitySearchKey::MisspelledWord || criteria.resultsLimit != 1) {
AX_ASSERT_NOT_REACHED();
return std::nullopt;
}
// If there's no start object, it means we want to search everything.
RefPtr startObject = criteria.startObject.get();
if (!startObject)
startObject = criteria.anchorObject.get();
AXLOG(startObject);
bool forward = criteria.searchDirection == AccessibilitySearchDirection::Next;
if (match(*startObject, criteria)) {
AX_ASSERT(m_misspellingRanges.contains(startObject->objectID()));
const auto& ranges = m_misspellingRanges.get(startObject->objectID());
AX_ASSERT(!ranges.isEmpty());
AXTextMarkerRange startRange { startObject->treeID(), startObject->objectID(), criteria.startRange };
if (forward) {
for (auto& range : ranges) {
if (range > startRange)
return range;
}
} else {
for (auto& range : ranges | std::views::reverse) {
if (range < startRange)
return range;
}
}
}
// Didn't find a matching range for startObject, thus move to the next/previous object.
auto stream = findMatchingObjectsInternalAsStream(criteria, /* remoteFrameSearchCallback */ { });
// Misspelling search is local-only, so just get the first local result from the stream.
for (const auto& entry : stream.entries()) {
if (RefPtr object = entry.objectIfLocalResult()) {
auto axID = object->objectID();
AX_ASSERT(m_misspellingRanges.contains(axID));
const auto& ranges = m_misspellingRanges.get(axID);
if (ranges.isEmpty()) {
AX_ASSERT_NOT_REACHED();
return std::nullopt;
}
return forward ? ranges[0] : ranges.last();
}
}
return std::nullopt;
}
} // namespace WebCore