Skip to content

Commit

Permalink
AX: VoiceOver doesn't read characters when user presses left / right …
Browse files Browse the repository at this point in the history
…arrows in Monaco code editor

https://bugs.webkit.org/show_bug.cgi?id=270616
rdar://123984168

Reviewed by Andres Gonzalez.

When the character or selection extent moves by just one visible position, infer
that it was a character granularity move, rather than a discontiguous selection.

* LayoutTests/accessibility/mac/custom-text-editor-expected.txt: Added.
* LayoutTests/accessibility/mac/custom-text-editor.html: Added.
* Source/WebCore/accessibility/AXObjectCache.h:
* Source/WebCore/accessibility/mac/AXObjectCacheMac.mm:
(WebCore::AXObjectCache::inferDirectionFromIntent):
(WebCore::AXObjectCache::postTextStateChangePlatformNotification):

Canonical link: https://commits.webkit.org/275998@main
  • Loading branch information
Dominic Mazzoni committed Mar 12, 2024
1 parent c0a2b30 commit 18d9a43
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 1 deletion.
24 changes: 24 additions & 0 deletions LayoutTests/accessibility/mac/custom-text-editor-expected.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Tests that we infer intents when JS is used to move the cursor in a text editor; this practice is common in online code editors.

PASS: addedNotification === true
Move to 0
move discontiguous
Move to 1
move next character
Move to 2
move next character
Move to 3
move next character
Move to 4
move next character
Move to 6
move next character
Move to 8
move discontiguous
Move to 9
move next character

PASS successfullyParsed is true

TEST COMPLETE

92 changes: 92 additions & 0 deletions LayoutTests/accessibility/mac/custom-text-editor.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML//EN">
<html>
<head>
<meta charset="utf-8">
<script src="../../resources/accessibility-helper.js"></script>
<script src="../../resources/js-test.js"></script>
</head>
<body>

<textarea autofocus id="text" role="textbox">Text😀area with custom cursor handling</textarea>

<script>
var output = "Tests that we infer intents when JS is used to move the cursor in a text editor; this practice is common in online code editors.\n\n";

const AXTextStateChangeTypeSelectionMove = 2;
const AXTextStateChangeTypeSelectionExtend = AXTextStateChangeTypeSelectionMove + 1;

const AXTextSelectionDirectionBeginning = 1;
const AXTextSelectionDirectionEnd = AXTextSelectionDirectionBeginning + 1;
const AXTextSelectionDirectionPrevious = AXTextSelectionDirectionEnd + 1;
const AXTextSelectionDirectionNext = AXTextSelectionDirectionPrevious + 1;
const AXTextSelectionDirectionDiscontiguous = AXTextSelectionDirectionNext + 1;

const AXTextSelectionGranularityCharacter = 1;

function notificationCallback(notification, userInfo) {
if (notification != "AXSelectedTextChanged")
return;

let str = "";
let type = userInfo["AXTextStateChangeType"];
if (type == AXTextStateChangeTypeSelectionMove)
str += "move";

let dir = userInfo["AXTextSelectionDirection"];
if (dir == AXTextSelectionDirectionNext)
str += " next";
else if (dir == AXTextSelectionDirectionPrevious)
str += " previous";
else if (dir == AXTextSelectionDirectionDiscontiguous)
str += " discontiguous";

let granularity = userInfo["AXTextSelectionGranularity"];
if (granularity == AXTextSelectionGranularityCharacter)
str += " character";

str += "\n";
output += str;

if (resolveNotificationPromise)
resolveNotificationPromise();
}

async function moveAndWaitForNotification(offset) {
text = document.getElementById("text");
output += "Move to " + offset + "\n";
let promise = new Promise((resolve, reject) => {
resolveNotificationPromise = resolve;
});
text.setSelectionRange(offset, offset);
await promise;
resolveNotificationPromise = null;
}

if (window.accessibilityController) {
window.jsTestIsAsync = true;

accessibilityController.enableEnhancedAccessibility(true);

webArea = accessibilityController.rootElement.childAtIndex(0);
var addedNotification = webArea.addNotificationListener(notificationCallback);
output += expect("addedNotification", "true");

setTimeout(async function() {
await moveAndWaitForNotification(0);
await moveAndWaitForNotification(1);
await moveAndWaitForNotification(2);
await moveAndWaitForNotification(3);
await moveAndWaitForNotification(4);

await moveAndWaitForNotification(6); // Note single character (emoji)

await moveAndWaitForNotification(8); // Discontiguous
await moveAndWaitForNotification(9);

debug(output);
finishJSTest();
}, 0);
}
</script>
</body>
</html>
9 changes: 9 additions & 0 deletions Source/WebCore/accessibility/AXObjectCache.h
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,10 @@ class AXObjectCache final : public CanMakeWeakPtr<AXObjectCache>, public CanMake
const HashSet<AXID>& relationTargetIDs();
bool isDescendantOfRelatedNode(Node&);

#if PLATFORM(MAC)
AXTextStateChangeIntent inferDirectionFromIntent(AccessibilityObject&, const AXTextStateChangeIntent&, const VisibleSelection&);
#endif

// Object creation.
Ref<AccessibilityObject> createObjectFromRenderer(RenderObject*);

Expand Down Expand Up @@ -778,6 +782,11 @@ class AXObjectCache final : public CanMakeWeakPtr<AXObjectCache>, public CanMake
#if USE(ATSPI)
ListHashSet<RefPtr<AXCoreObject>> m_deferredParentChangedList;
#endif

#if PLATFORM(MAC)
AXID m_lastTextFieldAXID;
VisibleSelection m_lastSelection;
#endif
};

template<typename U>
Expand Down
46 changes: 45 additions & 1 deletion Source/WebCore/accessibility/mac/AXObjectCacheMac.mm
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,49 @@ static void createIsolatedObjectIfNeeded(AccessibilityObject& object, std::optio
}
#endif

void AXObjectCache::postTextStateChangePlatformNotification(AccessibilityObject* object, const AXTextStateChangeIntent& intent, const VisibleSelection& selection)
AXTextStateChangeIntent AXObjectCache::inferDirectionFromIntent(AccessibilityObject& object, const AXTextStateChangeIntent& originalIntent, const VisibleSelection& selection)
{
if (!object.isTextControl() && !object.editableAncestor())
return originalIntent;

if (originalIntent.selection.direction != AXTextSelectionDirectionDiscontiguous || object.objectID() != m_lastTextFieldAXID || m_lastSelection == selection) {
m_lastTextFieldAXID = object.objectID();
m_lastSelection = selection;
return originalIntent;
}

auto intent = originalIntent;
if (m_lastSelection.isCaret() && selection.isCaret()) {
// Cursor movement
if (selection.visibleStart() == m_lastSelection.visibleStart().next(CannotCrossEditingBoundary)) {
intent.type = AXTextStateChangeTypeSelectionMove;
intent.selection.direction = AXTextSelectionDirectionNext;
intent.selection.granularity = AXTextSelectionGranularityCharacter;
} else if (selection.visibleStart() == m_lastSelection.visibleStart().previous(CannotCrossEditingBoundary)) {
intent.type = AXTextStateChangeTypeSelectionMove;
intent.selection.direction = AXTextSelectionDirectionPrevious;
intent.selection.granularity = AXTextSelectionGranularityCharacter;
}
} else if (selection.visibleBase() == m_lastSelection.visibleBase()) {
// Selection
if (selection.visibleExtent() == m_lastSelection.visibleExtent().next(CannotCrossEditingBoundary)) {
intent.type = AXTextStateChangeTypeSelectionExtend;
intent.selection.direction = AXTextSelectionDirectionNext;
intent.selection.granularity = AXTextSelectionGranularityCharacter;
} else if (selection.visibleExtent() == m_lastSelection.visibleExtent().previous(CannotCrossEditingBoundary)) {
intent.type = AXTextStateChangeTypeSelectionExtend;
intent.selection.direction = AXTextSelectionDirectionPrevious;
intent.selection.granularity = AXTextSelectionGranularityCharacter;
}
}

m_lastTextFieldAXID = object.objectID();
m_lastSelection = selection;

return intent;
}

void AXObjectCache::postTextStateChangePlatformNotification(AccessibilityObject* object, const AXTextStateChangeIntent& originalIntent, const VisibleSelection& selection)
{
if (!object)
object = rootWebArea();
Expand All @@ -519,6 +561,8 @@ static void createIsolatedObjectIfNeeded(AccessibilityObject& object, std::optio
processQueuedIsolatedNodeUpdates();
#endif

auto intent = inferDirectionFromIntent(*object, originalIntent, selection);

auto userInfo = adoptNS([[NSMutableDictionary alloc] initWithCapacity:5]);
if (m_isSynchronizingSelection)
[userInfo setObject:@YES forKey:NSAccessibilityTextStateSyncKey];
Expand Down

0 comments on commit 18d9a43

Please sign in to comment.