blob: 5473c39b5d6b14edf4d0a34206d6129824b2926f [file] [log] [blame]
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Handles automation from a desktop automation node.
*/
goog.provide('DesktopAutomationHandler');
goog.require('AutomationObjectConstructorInstaller');
goog.require('BaseAutomationHandler');
goog.require('ChromeVoxState');
goog.require('CustomAutomationEvent');
goog.require('editing.TextEditHandler');
goog.scope(function() {
var AutomationEvent = chrome.automation.AutomationEvent;
var AutomationNode = chrome.automation.AutomationNode;
var Dir = constants.Dir;
var EventType = chrome.automation.EventType;
var RoleType = chrome.automation.RoleType;
var StateType = chrome.automation.StateType;
/**
* @param {!AutomationNode} node
* @constructor
* @extends {BaseAutomationHandler}
*/
DesktopAutomationHandler = function(node) {
BaseAutomationHandler.call(this, node);
/**
* The object that speaks changes to an editable text field.
* @type {editing.TextEditHandler}
* @private
*/
this.textEditHandler_ = null;
/**
* The last time we handled a value changed event.
* @type {!Date}
* @private
*/
this.lastValueChanged_ = new Date(0);
/** @private {AutomationNode} */
this.lastValueTarget_ = null;
/** @private {string} */
this.lastRootUrl_ = '';
this.addListener_(
EventType.ACTIVEDESCENDANTCHANGED, this.onActiveDescendantChanged);
this.addListener_(EventType.ALERT, this.onAlert);
this.addListener_(
EventType.ARIA_ATTRIBUTE_CHANGED, this.onAriaAttributeChanged);
this.addListener_(EventType.AUTOCORRECTION_OCCURED, this.onEventIfInRange);
this.addListener_(EventType.BLUR, this.onBlur);
this.addListener_(
EventType.CHECKED_STATE_CHANGED, this.onCheckedStateChanged);
this.addListener_(EventType.CHILDREN_CHANGED, this.onChildrenChanged);
this.addListener_(EventType.EXPANDED_CHANGED, this.onEventIfInRange);
this.addListener_(EventType.FOCUS, this.onFocus);
this.addListener_(EventType.HOVER, this.onHover);
this.addListener_(EventType.INVALID_STATUS_CHANGED, this.onEventIfInRange);
this.addListener_(EventType.LOAD_COMPLETE, this.onLoadComplete);
this.addListener_(EventType.LOCATION_CHANGED, this.onLocationChanged);
this.addListener_(EventType.MENU_END, this.onMenuEnd);
this.addListener_(EventType.MENU_LIST_ITEM_SELECTED, this.onEventIfSelected);
this.addListener_(EventType.MENU_START, this.onMenuStart);
this.addListener_(EventType.ROW_COLLAPSED, this.onEventIfInRange);
this.addListener_(EventType.ROW_EXPANDED, this.onEventIfInRange);
this.addListener_(
EventType.SCROLL_POSITION_CHANGED, this.onScrollPositionChanged);
this.addListener_(EventType.SELECTION, this.onSelection);
this.addListener_(EventType.TEXT_CHANGED, this.onEditableChanged_);
this.addListener_(EventType.TEXT_SELECTION_CHANGED, this.onEditableChanged_);
this.addListener_(EventType.VALUE_CHANGED, this.onValueChanged);
AutomationObjectConstructorInstaller.init(node, function() {
chrome.automation.getFocus(
(function(focus) {
if (focus) {
var event =
new CustomAutomationEvent(EventType.FOCUS, focus, 'page');
this.onFocus(event);
}
}).bind(this));
}.bind(this));
};
/**
* Time to wait until processing more value changed events.
* @const {number}
*/
DesktopAutomationHandler.VMIN_VALUE_CHANGE_DELAY_MS = 500;
/**
* Controls announcement of non-user-initiated events.
* @type {boolean}
*/
DesktopAutomationHandler.announceActions = false;
/**
* The url of the keyboard.
* @const {string}
*/
DesktopAutomationHandler.KEYBOARD_URL =
'chrome-extension://jkghodnilhceideoidjikpgommlajknk/inputview.html';
DesktopAutomationHandler.prototype = {
__proto__: BaseAutomationHandler.prototype,
/** @override */
willHandleEvent_: function(evt) {
return !cvox.ChromeVox.isActive;
},
/**
* Provides all feedback once ChromeVox's focus changes.
* @param {!AutomationEvent} evt
*/
onEventDefault: function(evt) {
var node = evt.target;
if (!node)
return;
// Decide whether to announce and sync this event.
if (!DesktopAutomationHandler.announceActions && evt.eventFrom == 'action')
return;
var prevRange = ChromeVoxState.instance.currentRange;
ChromeVoxState.instance.setCurrentRange(cursors.Range.fromNode(node));
// Don't output if focused node hasn't changed.
if (prevRange && evt.type == 'focus' &&
ChromeVoxState.instance.currentRange.equals(prevRange))
return;
var output = new Output();
output.withRichSpeech(
ChromeVoxState.instance.currentRange, prevRange, evt.type);
if (!this.textEditHandler_) {
output.withBraille(
ChromeVoxState.instance.currentRange, prevRange, evt.type);
} else {
// Delegate event handling to the text edit handler for braille.
this.textEditHandler_.onEvent(evt);
}
output.go();
},
/**
* @param {!AutomationEvent} evt
*/
onEventIfInRange: function(evt) {
var prev = ChromeVoxState.instance.currentRange;
if (prev.contentEquals(cursors.Range.fromNode(evt.target)) ||
evt.target.state.focused) {
// Category flush here since previous focus events via navigation can
// cause double speak.
Output.forceModeForNextSpeechUtterance(cvox.QueueMode.CATEGORY_FLUSH);
// Intentionally skip setting range.
new Output()
.withRichSpeechAndBraille(
cursors.Range.fromNode(evt.target), prev,
Output.EventType.NAVIGATE)
.go();
}
},
/**
* @param {!AutomationEvent} evt
*/
onEventIfSelected: function(evt) {
if (evt.target.state.selected)
this.onEventDefault(evt);
},
/**
* @param {!AutomationEvent} evt
*/
onEventWithFlushedOutput: function(evt) {
Output.forceModeForNextSpeechUtterance(cvox.QueueMode.FLUSH);
this.onEventDefault(evt);
},
/**
* @param {!AutomationEvent} evt
*/
onAriaAttributeChanged: function(evt) {
if (evt.target.state.editable)
return;
this.onEventIfInRange(evt);
},
/**
* Handles the result of a hit test.
* @param {!AutomationNode} node The hit result.
*/
onHitTestResult: function(node) {
chrome.automation.getFocus(function(focus) {
if (!focus && !node)
return;
focus = node || focus;
var focusedRoot = AutomationUtil.getTopLevelRoot(focus);
var output = new Output();
if (focus != focusedRoot && focusedRoot)
output.format('$name', focusedRoot);
// Even though we usually don't output events from actions, hit test
// results should generate output.
var range = cursors.Range.fromNode(focus);
ChromeVoxState.instance.setCurrentRange(range);
output.withRichSpeechAndBraille(range, null, Output.EventType.NAVIGATE)
.go();
});
},
/**
* @param {!AutomationEvent} evt
*/
onHover: function(evt) {
var target = evt.target;
if (!AutomationPredicate.object(target)) {
target = AutomationUtil.findNodePre(
target, Dir.FORWARD, AutomationPredicate.object) ||
target;
}
if (ChromeVoxState.instance.currentRange &&
target == ChromeVoxState.instance.currentRange.start.node)
return;
Output.forceModeForNextSpeechUtterance(cvox.QueueMode.FLUSH);
this.onEventDefault(
new CustomAutomationEvent(evt.type, target, evt.eventFrom));
},
/**
* Handles active descendant changes.
* @param {!AutomationEvent} evt
*/
onActiveDescendantChanged: function(evt) {
if (!evt.target.activeDescendant || !evt.target.state.focused)
return;
var prevRange = ChromeVoxState.instance.currentRange;
var range = cursors.Range.fromNode(evt.target.activeDescendant);
ChromeVoxState.instance.setCurrentRange(range);
new Output()
.withRichSpeechAndBraille(range, prevRange, Output.EventType.NAVIGATE)
.go();
},
/**
* Makes an announcement without changing focus.
* @param {!AutomationEvent} evt
*/
onAlert: function(evt) {
var node = evt.target;
var range = cursors.Range.fromNode(node);
new Output()
.withSpeechCategory(cvox.TtsCategory.LIVE)
.withSpeechAndBraille(range, null, evt.type)
.go();
},
onBlur: function(evt) {
// Nullify focus if it no longer exists.
chrome.automation.getFocus(function(focus) {
if (!focus)
ChromeVoxState.instance.setCurrentRange(null);
});
},
/**
* Provides all feedback once a checked state changed event fires.
* @param {!AutomationEvent} evt
*/
onCheckedStateChanged: function(evt) {
if (!AutomationPredicate.checkable(evt.target))
return;
Output.forceModeForNextSpeechUtterance(cvox.QueueMode.CATEGORY_FLUSH);
var event = new CustomAutomationEvent(
EventType.CHECKED_STATE_CHANGED, evt.target, evt.eventFrom);
this.onEventIfInRange(event);
},
/**
* @param {!AutomationEvent} evt
*/
onChildrenChanged: function(evt) {
var curRange = ChromeVoxState.instance.currentRange;
// Always refresh the braille contents.
if (curRange && curRange.equals(cursors.Range.fromNode(evt.target))) {
new Output()
.withBraille(curRange, curRange, Output.EventType.NAVIGATE)
.go();
}
this.onActiveDescendantChanged(evt);
},
/**
* Provides all feedback once a focus event fires.
* @param {!AutomationEvent} evt
*/
onFocus: function(evt) {
if (evt.target.role == RoleType.ROOT_WEB_AREA) {
chrome.automation.getFocus(
this.maybeRecoverFocusAndOutput_.bind(this, evt));
return;
}
// Invalidate any previous editable text handler state.
if (!this.createTextEditHandlerIfNeeded_(evt.target))
this.textEditHandler_ = null;
var node = evt.target;
// Discard focus events on embeddedObject and webView.
if (node.role == RoleType.EMBEDDED_OBJECT || node.role == RoleType.WEB_VIEW)
return;
// Category flush speech triggered by events with no source. This includes
// views.
if (evt.eventFrom == '')
Output.forceModeForNextSpeechUtterance(cvox.QueueMode.CATEGORY_FLUSH);
if (!node.root)
return;
var event = new CustomAutomationEvent(EventType.FOCUS, node, evt.eventFrom);
this.onEventDefault(event);
// Refresh the handler, if needed, now that ChromeVox focus is up to date.
this.createTextEditHandlerIfNeeded_(node);
},
/**
* Provides all feedback once a load complete event fires.
* @param {!AutomationEvent} evt
*/
onLoadComplete: function(evt) {
// We are only interested in load completes on top level roots.
if (AutomationUtil.getTopLevelRoot(evt.target) != evt.target.root)
return;
this.lastRootUrl_ = '';
chrome.automation.getFocus(function(focus) {
// In some situations, ancestor windows get focused before a descendant
// webView/rootWebArea. In particular, a window that gets opened but no
// inner focus gets set. We catch this generically by re-targetting focus
// if focus is the ancestor of the load complete target (below).
var focusIsAncestor = AutomationUtil.isDescendantOf(evt.target, focus);
var focusIsDescendant = AutomationUtil.isDescendantOf(focus, evt.target);
if (!focus || (!focusIsAncestor && !focusIsDescendant))
return;
if (focusIsAncestor) {
focus = evt.target;
Output.forceModeForNextSpeechUtterance(cvox.QueueMode.FLUSH);
}
// Create text edit handler, if needed, now in order not to miss initial
// value change if text field has already been focused when initializing
// ChromeVox.
this.createTextEditHandlerIfNeeded_(focus);
// If auto read is set, skip focus recovery and start reading from the
// top.
if (localStorage['autoRead'] == 'true' &&
AutomationUtil.getTopLevelRoot(evt.target) == evt.target) {
ChromeVoxState.instance.setCurrentRange(
cursors.Range.fromNode(evt.target));
cvox.ChromeVox.tts.stop();
CommandHandler.onCommand('readFromHere');
return;
}
this.maybeRecoverFocusAndOutput_(evt, focus);
}.bind(this));
},
/**
* Updates the focus ring if the location of the current range, or
* an ancestor of the current range, changes.
* @param {!AutomationEvent} evt
*/
onLocationChanged: function(evt) {
var cur = ChromeVoxState.instance.currentRange;
if (AutomationUtil.isDescendantOf(cur.start.node, evt.target) ||
AutomationUtil.isDescendantOf(cur.end.node, evt.target)) {
new Output().withLocation(cur, null, evt.type).go();
}
},
/**
* Provides all feedback once a change event in a text field fires.
* @param {!AutomationEvent} evt
* @private
*/
onEditableChanged_: function(evt) {
if (!this.createTextEditHandlerIfNeeded_(evt.target))
return;
if (!ChromeVoxState.instance.currentRange) {
this.onEventDefault(evt);
ChromeVoxState.instance.setCurrentRange(
cursors.Range.fromNode(evt.target));
}
// Sync the ChromeVox range to the editable, if a selection exists.
var anchorObject = evt.target.root.anchorObject;
var anchorOffset = evt.target.root.anchorOffset || 0;
var focusObject = evt.target.root.focusObject;
var focusOffset = evt.target.root.focusOffset || 0;
if (anchorObject && focusObject) {
var selectedRange = new cursors.Range(
new cursors.WrappingCursor(anchorObject, anchorOffset),
new cursors.WrappingCursor(focusObject, focusOffset));
// Sync ChromeVox range with selection.
ChromeVoxState.instance.setCurrentRange(selectedRange);
}
this.textEditHandler_.onEvent(evt);
},
/**
* Provides all feedback once a value changed event fires.
* @param {!AutomationEvent} evt
*/
onValueChanged: function(evt) {
// Skip all unfocused text fields.
if (!evt.target.state[StateType.FOCUSED] &&
evt.target.state[StateType.EDITABLE])
return;
// Delegate to the edit text handler if this is an editable.
if (evt.target.state[StateType.EDITABLE]) {
this.onEditableChanged_(evt);
return;
}
var t = evt.target;
var fromDesktop = t.root.role == RoleType.DESKTOP;
if (t.state.focused || fromDesktop ||
AutomationUtil.isDescendantOf(
ChromeVoxState.instance.currentRange.start.node, t)) {
if (new Date() - this.lastValueChanged_ <=
DesktopAutomationHandler.VMIN_VALUE_CHANGE_DELAY_MS)
return;
this.lastValueChanged_ = new Date();
var output = new Output();
if (fromDesktop &&
(!this.lastValueTarget_ || this.lastValueTarget_ !== t)) {
output.withQueueMode(cvox.QueueMode.FLUSH);
var range = cursors.Range.fromNode(t);
output.withRichSpeechAndBraille(
range, range, Output.EventType.NAVIGATE);
this.lastValueTarget_ = t;
} else {
output.format('$value', t);
}
output.go();
}
},
/**
* Handle updating the active indicator when the document scrolls.
* @param {!AutomationEvent} evt
*/
onScrollPositionChanged: function(evt) {
var currentRange = ChromeVoxState.instance.currentRange;
if (currentRange && currentRange.isValid())
new Output().withLocation(currentRange, null, evt.type).go();
},
/**
* @param {!AutomationEvent} evt
*/
onSelection: function(evt) {
// Invalidate any previous editable text handler state since some nodes,
// like menuitems, can receive selection while focus remains on an editable
// leading to braille output routing to the editable.
this.textEditHandler_ = null;
chrome.automation.getFocus(function(focus) {
// Desktop tabs get "selection" when there's a focused webview during tab
// switching.
if (focus.role == RoleType.WEB_VIEW && evt.target.role == RoleType.TAB) {
ChromeVoxState.instance.setCurrentRange(
cursors.Range.fromNode(focus.firstChild));
return;
}
// Some cases (e.g. in overview mode), require overriding the assumption
// that focus is an ancestor of a selection target.
var override = evt.target.role == RoleType.MENU_ITEM ||
(evt.target.root == focus.root &&
focus.root.role == RoleType.DESKTOP);
Output.forceModeForNextSpeechUtterance(cvox.QueueMode.FLUSH);
if (override || AutomationUtil.isDescendantOf(evt.target, focus))
this.onEventDefault(evt);
}.bind(this));
},
/**
* Provides all feedback once a menu start event fires.
* @param {!AutomationEvent} evt
*/
onMenuStart: function(evt) {
ChromeVoxState.instance.markCurrentRange();
this.onEventDefault(evt);
},
/**
* Provides all feedback once a menu end event fires.
* @param {!AutomationEvent} evt
*/
onMenuEnd: function(evt) {
this.onEventDefault(evt);
// This is a work around for Chrome context menus not firing a focus event
// after you close them.
chrome.automation.getFocus(function(focus) {
if (focus) {
var event = new CustomAutomationEvent(EventType.FOCUS, focus, 'page');
this.onFocus(event);
}
}.bind(this));
},
/**
* Create an editable text handler for the given node if needed.
* @param {!AutomationNode} node
* @return {boolean} True if the handler exists (created/already present).
*/
createTextEditHandlerIfNeeded_: function(node) {
if (!node.state.editable)
return false;
if (!ChromeVoxState.instance.currentRange ||
!ChromeVoxState.instance.currentRange.start ||
!ChromeVoxState.instance.currentRange.start.node)
return false;
var topRoot = AutomationUtil.getTopLevelRoot(node);
if (!node.state.focused ||
(topRoot && topRoot.parent && !topRoot.parent.state.focused))
return false;
// Re-target the node to the root of the editable.
var target = node;
target = AutomationUtil.getEditableRoot(target);
var voxTarget = ChromeVoxState.instance.currentRange.start.node;
voxTarget = AutomationUtil.getEditableRoot(voxTarget) || voxTarget;
// It is possible that ChromeVox has range over some other node when a text
// field is focused. Only allow this when focus is on a desktop node or
// ChromeVox is over the keyboard.
if (!target || !voxTarget ||
(target != voxTarget && target.root.role != RoleType.DESKTOP &&
voxTarget.root.role != RoleType.DESKTOP &&
voxTarget.root.url.indexOf(DesktopAutomationHandler.KEYBOARD_URL) !=
0))
return false;
if (target.restriction == chrome.automation.Restriction.READ_ONLY)
return false;
if (!this.textEditHandler_ || this.textEditHandler_.node !== target) {
this.textEditHandler_ = editing.TextEditHandler.createForNode(target);
}
return !!this.textEditHandler_;
},
/**
* @param {AutomationEvent} evt
* @private
*/
maybeRecoverFocusAndOutput_: function(evt, focus) {
var focusedRoot = AutomationUtil.getTopLevelRoot(focus);
if (!focusedRoot)
return;
var curRoot;
if (ChromeVoxState.instance.currentRange) {
curRoot = AutomationUtil.getTopLevelRoot(
ChromeVoxState.instance.currentRange.start.node);
}
// If initial focus was already placed inside this page (e.g. if a user
// starts tabbing before load complete), then don't move ChromeVox's
// position on the page.
if (curRoot && focusedRoot == curRoot &&
this.lastRootUrl_ == focusedRoot.docUrl)
return;
this.lastRootUrl_ = focusedRoot.docUrl || '';
var o = new Output();
// Restore to previous position.
var url = focusedRoot.docUrl;
url = url.substring(0, url.indexOf('#')) || url;
var pos = cvox.ChromeVox.position[url];
if (pos) {
focusedRoot.hitTestWithReply(
pos.x, pos.y, this.onHitTestResult.bind(this));
return;
}
// This catches initial focus (i.e. on startup).
if (!curRoot && focus != focusedRoot)
o.format('$name', focusedRoot);
ChromeVoxState.instance.setCurrentRange(cursors.Range.fromNode(focus));
Output.forceModeForNextSpeechUtterance(cvox.QueueMode.FLUSH);
o.withRichSpeechAndBraille(
ChromeVoxState.instance.currentRange, null, evt.type)
.go();
}
};
/**
* Initializes global state for DesktopAutomationHandler.
* @private
*/
DesktopAutomationHandler.init_ = function() {
chrome.automation.getDesktop(function(desktop) {
ChromeVoxState.desktopAutomationHandler =
new DesktopAutomationHandler(desktop);
});
};
DesktopAutomationHandler.init_();
}); // goog.scope