Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { Actor } = require("resource://devtools/shared/protocol.js");
const {
accessibleWalkerSpec,
const {
simulation: { COLOR_TRANSFORMATION_MATRICES },
loader.lazyRequireGetter(
this,
"AccessibleActor",
true
);
loader.lazyRequireGetter(
this,
["CustomHighlighterActor"],
true
);
loader.lazyRequireGetter(
this,
"DevToolsUtils",
);
loader.lazyRequireGetter(
this,
"events",
);
loader.lazyRequireGetter(
this,
["isWindowIncluded", "isFrameWithChildTarget"],
true
);
loader.lazyRequireGetter(
this,
"isXUL",
true
);
loader.lazyRequireGetter(
this,
[
"isDefunct",
"loadSheetForBackgroundCalculation",
"removeSheetForBackgroundCalculation",
],
true
);
loader.lazyRequireGetter(
this,
"accessibility",
true
);
const kStateHover = 0x00000004; // ElementState::HOVER
const {
EVENT_TEXT_CHANGED,
EVENT_TEXT_INSERTED,
EVENT_TEXT_REMOVED,
EVENT_ACCELERATOR_CHANGE,
EVENT_ACTION_CHANGE,
EVENT_DEFACTION_CHANGE,
EVENT_DESCRIPTION_CHANGE,
EVENT_DOCUMENT_ATTRIBUTES_CHANGED,
EVENT_HIDE,
EVENT_NAME_CHANGE,
EVENT_OBJECT_ATTRIBUTE_CHANGED,
EVENT_REORDER,
EVENT_STATE_CHANGE,
EVENT_TEXT_ATTRIBUTE_CHANGED,
EVENT_VALUE_CHANGE,
} = Ci.nsIAccessibleEvent;
// TODO: We do not need this once bug 1422913 is fixed. We also would not need
// to fire a name change event for an accessible that has an updated subtree and
// that has its name calculated from the said subtree.
const NAME_FROM_SUBTREE_RULE_ROLES = new Set([
Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWN,
Ci.nsIAccessibleRole.ROLE_BUTTONMENU,
Ci.nsIAccessibleRole.ROLE_CELL,
Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM,
Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
Ci.nsIAccessibleRole.ROLE_COLUMNHEADER,
Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION,
Ci.nsIAccessibleRole.ROLE_DEFINITION,
Ci.nsIAccessibleRole.ROLE_GRID_CELL,
Ci.nsIAccessibleRole.ROLE_HEADING,
Ci.nsIAccessibleRole.ROLE_KEY,
Ci.nsIAccessibleRole.ROLE_LABEL,
Ci.nsIAccessibleRole.ROLE_LINK,
Ci.nsIAccessibleRole.ROLE_LISTITEM,
Ci.nsIAccessibleRole.ROLE_MATHML_IDENTIFIER,
Ci.nsIAccessibleRole.ROLE_MATHML_NUMBER,
Ci.nsIAccessibleRole.ROLE_MATHML_OPERATOR,
Ci.nsIAccessibleRole.ROLE_MATHML_TEXT,
Ci.nsIAccessibleRole.ROLE_MATHML_STRING_LITERAL,
Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH,
Ci.nsIAccessibleRole.ROLE_MENUITEM,
Ci.nsIAccessibleRole.ROLE_OPTION,
Ci.nsIAccessibleRole.ROLE_OUTLINEITEM,
Ci.nsIAccessibleRole.ROLE_PAGETAB,
Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM,
Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
Ci.nsIAccessibleRole.ROLE_RICH_OPTION,
Ci.nsIAccessibleRole.ROLE_ROW,
Ci.nsIAccessibleRole.ROLE_ROWHEADER,
Ci.nsIAccessibleRole.ROLE_SUMMARY,
Ci.nsIAccessibleRole.ROLE_SWITCH,
Ci.nsIAccessibleRole.ROLE_TERM,
Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
Ci.nsIAccessibleRole.ROLE_TOOLTIP,
]);
const IS_OSX = Services.appinfo.OS === "Darwin";
const {
SCORES: { BEST_PRACTICES, FAIL, WARNING },
} = accessibility;
/**
* Helper function that determines if nsIAccessible object is in stale state. When an
* object is stale it means its subtree is not up to date.
*
* @param {nsIAccessible} accessible
* object to be tested.
* @return {Boolean}
* True if accessible object is stale, false otherwise.
*/
function isStale(accessible) {
const extraState = {};
accessible.getState({}, extraState);
// extraState.value is a bitmask. We are applying bitwise AND to mask out
// irrelevant states.
return !!(extraState.value & Ci.nsIAccessibleStates.EXT_STATE_STALE);
}
/**
* Get accessibility audit starting with the passed accessible object as a root.
*
* @param {Object} acc
* AccessibileActor to be used as the root for the audit.
* @param {Object} options
* Options for running audit, may include:
* - types: Array of audit types to be performed during audit.
* @param {Map} report
* An accumulator map to be used to store audit information.
* @param {Object} progress
* An audit project object that is used to track the progress of the
* audit and send progress "audit-event" events to the client.
*/
function getAudit(acc, options, report, progress) {
if (acc.isDefunct) {
return;
}
// Audit returns a promise, save the actual value in the report.
report.set(
acc,
acc.audit(options).then(result => {
report.set(acc, result);
progress.increment();
})
);
for (const child of acc.children()) {
getAudit(child, options, report, progress);
}
}
/**
* A helper class that is used to track audit progress and send progress events
* to the client.
*/
class AuditProgress {
constructor(walker) {
this.completed = 0;
this.percentage = 0;
this.walker = walker;
}
setTotal(size) {
this.size = size;
}
notify() {
this.walker.emit("audit-event", {
type: "progress",
progress: {
total: this.size,
percentage: this.percentage,
completed: this.completed,
},
});
}
increment() {
this.completed++;
const { completed, size } = this;
if (!size) {
return;
}
const percentage = Math.round((completed / size) * 100);
if (percentage > this.percentage) {
this.percentage = percentage;
this.notify();
}
}
destroy() {
this.walker = null;
}
}
/**
* The AccessibleWalkerActor stores a cache of AccessibleActors that represent
* accessible objects in a given document.
*
* It is also responsible for implicitely initializing and shutting down
* accessibility engine by storing a reference to the XPCOM accessibility
* service.
*/
class AccessibleWalkerActor extends Actor {
constructor(conn, targetActor) {
super(conn, accessibleWalkerSpec);
this.targetActor = targetActor;
this.refMap = new Map();
this._loadedSheets = new WeakMap();
this.setA11yServiceGetter();
this.onPick = this.onPick.bind(this);
this.onHovered = this.onHovered.bind(this);
this._preventContentEvent = this._preventContentEvent.bind(this);
this.onKey = this.onKey.bind(this);
this.onFocusIn = this.onFocusIn.bind(this);
this.onFocusOut = this.onFocusOut.bind(this);
this.onHighlighterEvent = this.onHighlighterEvent.bind(this);
}
get highlighter() {
if (!this._highlighter) {
this._highlighter = new CustomHighlighterActor(
this,
"AccessibleHighlighter"
);
this.manage(this._highlighter);
this._highlighter.on("highlighter-event", this.onHighlighterEvent);
}
return this._highlighter;
}
get tabbingOrderHighlighter() {
if (!this._tabbingOrderHighlighter) {
this._tabbingOrderHighlighter = new CustomHighlighterActor(
this,
"TabbingOrderHighlighter"
);
this.manage(this._tabbingOrderHighlighter);
}
return this._tabbingOrderHighlighter;
}
setA11yServiceGetter() {
DevToolsUtils.defineLazyGetter(this, "a11yService", () => {
Services.obs.addObserver(this, "accessible-event");
return Cc["@mozilla.org/accessibilityService;1"].getService(
Ci.nsIAccessibilityService
);
});
}
get rootWin() {
return this.targetActor && this.targetActor.window;
}
get rootDoc() {
return this.targetActor && this.targetActor.window.document;
}
get isXUL() {
return isXUL(this.rootWin);
}
get colorMatrix() {
if (!this.targetActor.docShell) {
return null;
}
const colorMatrix = this.targetActor.docShell.getColorMatrix();
if (
colorMatrix.length === 0 ||
colorMatrix === COLOR_TRANSFORMATION_MATRICES.NONE
) {
return null;
}
return colorMatrix;
}
reset() {
try {
Services.obs.removeObserver(this, "accessible-event");
} catch (e) {
// Accessible event observer might not have been initialized if a11y
// service was never used.
}
this.cancelPick();
// Clean up accessible actors cache.
this.clearRefs();
this._childrenPromise = null;
delete this.a11yService;
this.setA11yServiceGetter();
}
/**
* Remove existing cache (of accessible actors) from tree.
*/
clearRefs() {
for (const actor of this.refMap.values()) {
actor.destroy();
}
}
destroy() {
super.destroy();
this.reset();
if (this._highlighter) {
this._highlighter.off("highlighter-event", this.onHighlighterEvent);
this._highlighter = null;
}
if (this._tabbingOrderHighlighter) {
this._tabbingOrderHighlighter = null;
}
this.targetActor = null;
this.refMap = null;
}
getRef(rawAccessible) {
return this.refMap.get(rawAccessible);
}
addRef(rawAccessible) {
let actor = this.refMap.get(rawAccessible);
if (actor) {
return actor;
}
actor = new AccessibleActor(this, rawAccessible);
// Add the accessible actor as a child of this accessible walker actor,
// assigning it an actorID.
this.manage(actor);
this.refMap.set(rawAccessible, actor);
return actor;
}
/**
* Clean up accessible actors cache for a given accessible's subtree.
*
* @param {null|nsIAccessible} rawAccessible
*/
purgeSubtree(rawAccessible) {
if (!rawAccessible) {
return;
}
try {
for (
let child = rawAccessible.firstChild;
child;
child = child.nextSibling
) {
this.purgeSubtree(child);
}
} catch (e) {
// rawAccessible or its descendants are defunct.
}
const actor = this.getRef(rawAccessible);
if (actor) {
actor.destroy();
}
}
unmanage(actor) {
if (actor instanceof AccessibleActor) {
this.refMap.delete(actor.rawAccessible);
}
Actor.prototype.unmanage.call(this, actor);
}
/**
* A helper method. Accessibility walker is assumed to have only 1 child which
* is the top level document.
*/
async children() {
if (this._childrenPromise) {
return this._childrenPromise;
}
this._childrenPromise = Promise.all([this.getDocument()]);
const children = await this._childrenPromise;
this._childrenPromise = null;
return children;
}
/**
* A promise for a root document accessible actor that only resolves when its
* corresponding document accessible object is fully loaded.
*
* @return {Promise}
*/
getDocument() {
if (!this.rootDoc || !this.rootDoc.documentElement) {
return this.once("document-ready").then(docAcc => this.addRef(docAcc));
}
if (this.isXUL) {
const doc = this.addRef(this.getRawAccessibleFor(this.rootDoc));
return Promise.resolve(doc);
}
const doc = this.getRawAccessibleFor(this.rootDoc);
// For non-visible same-process iframes we don't get a document and
// won't get a "document-ready" event.
if (!doc && !this.rootWin.windowGlobalChild.isProcessRoot) {
// We can ignore such document as there won't be anything to audit in them.
return null;
}
if (!doc || isStale(doc)) {
return this.once("document-ready").then(docAcc => this.addRef(docAcc));
}
return Promise.resolve(this.addRef(doc));
}
/**
* Get an accessible actor for a domnode actor.
* @param {Object} domNode
* domnode actor for which accessible actor is being created.
* @return {Promse}
* A promise that resolves when accessible actor is created for a
* domnode actor.
*/
getAccessibleFor(domNode) {
// We need to make sure that the document is loaded processed by a11y first.
return this.getDocument().then(() => {
const rawAccessible = this.getRawAccessibleFor(domNode.rawNode);
// Not all DOM nodes have corresponding accessible objects. It's usually
// the case where there is no semantics or relevance to the accessibility
// client.
if (!rawAccessible) {
return null;
}
return this.addRef(rawAccessible);
});
}
/**
* Get a raw accessible object for a raw node.
* @param {DOMNode} rawNode
* Raw node for which accessible object is being retrieved.
* @return {nsIAccessible}
* Accessible object for a given DOMNode.
*/
getRawAccessibleFor(rawNode) {
// Accessible can only be retrieved iff accessibility service is enabled.
if (!Services.appinfo.accessibilityEnabled) {
return null;
}
return this.a11yService.getAccessibleFor(rawNode);
}
async getAncestry(accessible) {
if (!accessible || accessible.indexInParent === -1) {
return [];
}
const doc = await this.getDocument();
if (!doc) {
return [];
}
const ancestry = [];
if (accessible === doc) {
return ancestry;
}
try {
let parent = accessible;
while (parent && (parent = parent.parentAcc) && parent != doc) {
ancestry.push(parent);
}
ancestry.push(doc);
} catch (error) {
throw new Error(`Failed to get ancestor for ${accessible}: ${error}`);
}
return ancestry.map(parent => ({
accessible: parent,
children: parent.children(),
}));
}
/**
* Run accessibility audit and return relevant ancestries for AccessibleActors
* that have non-empty audit checks.
*
* @param {Object} options
* Options for running audit, may include:
* - types: Array of audit types to be performed during audit.
*
* @return {Promise}
* A promise that resolves when the audit is complete and all relevant
* ancestries are calculated.
*/
async audit(options) {
const doc = await this.getDocument();
if (!doc) {
return [];
}
const report = new Map();
this._auditProgress = new AuditProgress(this);
getAudit(doc, options, report, this._auditProgress);
this._auditProgress.setTotal(report.size);
await Promise.all(report.values());
const ancestries = [];
for (const [acc, audit] of report.entries()) {
// Filter out audits that have no failing checks.
if (
audit &&
Object.values(audit).some(
check =>
check != null &&
!check.error &&
[BEST_PRACTICES, FAIL, WARNING].includes(check.score)
)
) {
ancestries.push(this.getAncestry(acc));
}
}
return Promise.all(ancestries);
}
/**
* Start accessibility audit. The result of this function will not be an audit
* report. Instead, an "audit-event" event will be fired when the audit is
* completed or fails.
*
* @param {Object} options
* Options for running audit, may include:
* - types: Array of audit types to be performed during audit.
*/
startAudit(options) {
// Audit is already running, wait for the "audit-event" event.
if (this._auditing) {
return;
}
this._auditing = this.audit(options)
// We do not want to block on audit request, instead fire "audit-event"
// event when internal audit is finished or failed.
.then(ancestries =>
this.emit("audit-event", {
type: "completed",
ancestries,
})
)
.catch(() => this.emit("audit-event", { type: "error" }))
.finally(() => {
this._auditing = null;
if (this._auditProgress) {
this._auditProgress.destroy();
this._auditProgress = null;
}
});
}
onHighlighterEvent(data) {
this.emit("highlighter-event", data);
}
/**
* Accessible event observer function.
*
* @param {Ci.nsIAccessibleEvent} subject
* accessible event object.
*/
// eslint-disable-next-line complexity
observe(subject) {
const event = subject.QueryInterface(Ci.nsIAccessibleEvent);
const rawAccessible = event.accessible;
const accessible = this.getRef(rawAccessible);
if (rawAccessible instanceof Ci.nsIAccessibleDocument && !accessible) {
const rootDocAcc = this.getRawAccessibleFor(this.rootDoc);
if (rawAccessible === rootDocAcc && !isStale(rawAccessible)) {
this.clearRefs();
// If it's a top level document notify listeners about the document
// being ready.
events.emit(this, "document-ready", rawAccessible);
}
}
switch (event.eventType) {
case EVENT_STATE_CHANGE:
const { state, isEnabled } = event.QueryInterface(
Ci.nsIAccessibleStateChangeEvent
);
const isBusy = state & Ci.nsIAccessibleStates.STATE_BUSY;
if (accessible) {
// Only propagate state change events for active accessibles.
if (isBusy && isEnabled) {
if (rawAccessible instanceof Ci.nsIAccessibleDocument) {
// Remove existing cache from tree.
this.clearRefs();
}
return;
}
events.emit(accessible, "states-change", accessible.states);
}
break;
case EVENT_NAME_CHANGE:
if (accessible) {
events.emit(
accessible,
"name-change",
rawAccessible.name,
event.DOMNode == this.rootDoc
? undefined
: this.getRef(rawAccessible.parent)
);
}
break;
case EVENT_VALUE_CHANGE:
if (accessible) {
events.emit(accessible, "value-change", rawAccessible.value);
}
break;
case EVENT_DESCRIPTION_CHANGE:
if (accessible) {
events.emit(
accessible,
"description-change",
rawAccessible.description
);
}
break;
case EVENT_REORDER:
if (accessible) {
accessible
.children()
.forEach(child =>
events.emit(child, "index-in-parent-change", child.indexInParent)
);
events.emit(accessible, "reorder", rawAccessible.childCount);
}
break;
case EVENT_HIDE:
if (event.DOMNode == this.rootDoc) {
this.clearRefs();
} else {
this.purgeSubtree(rawAccessible);
}
break;
case EVENT_DEFACTION_CHANGE:
case EVENT_ACTION_CHANGE:
if (accessible) {
events.emit(accessible, "actions-change", accessible.actions);
}
break;
case EVENT_TEXT_CHANGED:
case EVENT_TEXT_INSERTED:
case EVENT_TEXT_REMOVED:
if (accessible) {
events.emit(accessible, "text-change");
if (NAME_FROM_SUBTREE_RULE_ROLES.has(rawAccessible.role)) {
events.emit(
accessible,
"name-change",
rawAccessible.name,
event.DOMNode == this.rootDoc
? undefined
: this.getRef(rawAccessible.parent)
);
}
}
break;
case EVENT_DOCUMENT_ATTRIBUTES_CHANGED:
case EVENT_OBJECT_ATTRIBUTE_CHANGED:
case EVENT_TEXT_ATTRIBUTE_CHANGED:
if (accessible) {
events.emit(accessible, "attributes-change", accessible.attributes);
}
break;
// EVENT_ACCELERATOR_CHANGE is currently not fired by gecko accessibility.
case EVENT_ACCELERATOR_CHANGE:
if (accessible) {
events.emit(
accessible,
"shortcut-change",
accessible.keyboardShortcut
);
}
break;
default:
break;
}
}
/**
* Ensure that nothing interferes with the audit for an accessible object
* (CSS, overlays) by load accessibility highlighter style sheet used for
* preventing transitions and applying transparency when calculating colour
* contrast as well as temporarily hiding accessible highlighter overlay.
* @param {Object} win
* Window where highlighting happens.
*/
async clearStyles(win) {
const requests = this._loadedSheets.get(win);
if (requests != null) {
this._loadedSheets.set(win, requests + 1);
return;
}
// Disable potential mouse driven transitions (This is important because accessibility
// highlighter temporarily modifies text color related CSS properties. In case where
// there are transitions that affect them, there might be unexpected side effects when
// taking a snapshot for contrast measurement).
loadSheetForBackgroundCalculation(win);
this._loadedSheets.set(win, 1);
await this.hideHighlighter();
}
/**
* Restore CSS and overlays that could've interfered with the audit for an
* accessible object by unloading accessibility highlighter style sheet used
* for preventing transitions and applying transparency when calculating
* colour contrast and potentially restoring accessible highlighter overlay.
* @param {Object} win
* Window where highlighting was happenning.
*/
async restoreStyles(win) {
const requests = this._loadedSheets.get(win);
if (!requests) {
return;
}
if (requests > 1) {
this._loadedSheets.set(win, requests - 1);
return;
}
await this.showHighlighter();
removeSheetForBackgroundCalculation(win);
this._loadedSheets.delete(win);
}
async hideHighlighter() {
// TODO: Fix this workaround that temporarily removes higlighter bounds
// overlay that can interfere with the contrast ratio calculation.
if (this._highlighter) {
const highlighter = this._highlighter.instance;
await highlighter.isReady;
highlighter.hideAccessibleBounds();
}
}
async showHighlighter() {
// TODO: Fix this workaround that temporarily removes higlighter bounds
// overlay that can interfere with the contrast ratio calculation.
if (this._highlighter) {
const highlighter = this._highlighter.instance;
await highlighter.isReady;
highlighter.showAccessibleBounds();
}
}
/**
* Public method used to show an accessible object highlighter on the client
* side.
*
* @param {Object} accessible
* AccessibleActor to be highlighted.
* @param {Object} options
* Object used for passing options. Available options:
* - duration {Number}
* Duration of time that the highlighter should be shown.
* @return {Boolean}
* True if highlighter shows the accessible object.
*/
async highlightAccessible(accessible, options = {}) {
this.unhighlight();
// Do not highlight if accessible is dead.
if (!accessible || accessible.isDefunct || accessible.indexInParent < 0) {
return false;
}
this._highlightingAccessible = accessible;
const { bounds } = accessible;
if (!bounds) {
return false;
}
const { DOMNode: rawNode } = accessible.rawAccessible;
const audit = await accessible.audit();
if (this._highlightingAccessible !== accessible) {
return false;
}
const { name, role } = accessible;
const { highlighter } = this;
await highlighter.instance.isReady;
if (this._highlightingAccessible !== accessible) {
return false;
}
const shown = highlighter.show(
{ rawNode },
{ ...options, ...bounds, name, role, audit, isXUL: this.isXUL }
);
this._highlightingAccessible = null;
return shown;
}
/**
* Public method used to hide an accessible object highlighter on the client
* side.
*/
unhighlight() {
if (!this._highlighter) {
return;
}
this.highlighter.hide();
this._highlightingAccessible = null;
}
/**
* Picking state that indicates if picking is currently enabled and, if so,
* what the current and hovered accessible objects are.
*/
_isPicking = false;
_currentAccessible = null;
/**
* Check is event handling is allowed.
*/
_isEventAllowed({ view }) {
return this.rootWin.isChromeWindow || isWindowIncluded(this.rootWin, view);
}
/**
* Check if the DOM event received when picking shold be ignored.
* @param {Event} event
*/
_ignoreEventWhenPicking(event) {
return (
!this._isPicking ||
// If the DOM event is about a remote frame, only the WalkerActor for that
// remote frame target should emit RDP events (hovered/picked/...). And
// all other WalkerActor for intermediate iframe and top level document
// targets should stay silent.
isFrameWithChildTarget(
this.targetActor,
event.originalTarget || event.target
)
);
}
_preventContentEvent(event) {
if (this._ignoreEventWhenPicking(event)) {
return;
}
event.stopPropagation();
event.preventDefault();
const target = event.originalTarget || event.target;
if (target !== this._currentTarget) {
this._resetStateAndReleaseTarget();
this._currentTarget = target;
// We use InspectorUtils to save the original hover content state of the target
// element (that includes its hover state). In order to not trigger any visual
// changes to the element that depend on its hover state we remove the state while
// the element is the most current target of the highlighter.
//
// TODO: This logic can be removed if/when we can use elementsAtPoint API for
// determining topmost DOMNode that corresponds to specific coordinates. We would
// then be able to use a highlighter overlay that would prevent all pointer events
// to content but still render highlighter for the node/element correctly.
this._currentTargetHoverState =
InspectorUtils.getContentState(target) & kStateHover;
InspectorUtils.removeContentState(target, kStateHover);
}
}
/**
* Click event handler for when picking is enabled.
*
* @param {Object} event
* Current click event.
*/
onPick(event) {
if (this._ignoreEventWhenPicking(event)) {
return;
}
this._preventContentEvent(event);
if (!this._isEventAllowed(event)) {
return;
}
// If shift is pressed, this is only a preview click, send the event to
// the client, but don't stop picking.
if (event.shiftKey) {
if (!this._currentAccessible) {
this._currentAccessible = this._findAndAttachAccessible(event);
}
events.emit(this, "picker-accessible-previewed", this._currentAccessible);
return;
}
this._unsetPickerEnvironment();
this._isPicking = false;
if (!this._currentAccessible) {
this._currentAccessible = this._findAndAttachAccessible(event);
}
events.emit(this, "picker-accessible-picked", this._currentAccessible);
}
/**
* Hover event handler for when picking is enabled.
*
* @param {Object} event
* Current hover event.
*/
async onHovered(event) {
if (this._ignoreEventWhenPicking(event)) {
return;
}
this._preventContentEvent(event);
if (!this._isEventAllowed(event)) {
return;
}
const accessible = this._findAndAttachAccessible(event);
if (!accessible || this._currentAccessible === accessible) {
return;
}
this._currentAccessible = accessible;
// Highlight current accessible and by the time we are done, if accessible that was
// highlighted is not current any more (user moved the mouse to a new node) highlight
// the most current accessible again.
const shown = await this.highlightAccessible(accessible);
if (this._isPicking && shown && accessible === this._currentAccessible) {
events.emit(this, "picker-accessible-hovered", accessible);
}
}
/**
* Keyboard event handler for when picking is enabled.
*
* @param {Object} event
* Current keyboard event.
*/
onKey(event) {
if (!this._currentAccessible || this._ignoreEventWhenPicking(event)) {
return;
}
this._preventContentEvent(event);
if (!this._isEventAllowed(event)) {
return;
}
/**
* KEY: Action/scope
* ENTER/CARRIAGE_RETURN: Picks current accessible
* ESC/CTRL+SHIFT+C: Cancels picker
*/
switch (event.keyCode) {
// Select the element.
case event.DOM_VK_RETURN:
this.onPick(event);
break;
// Cancel pick mode.
case event.DOM_VK_ESCAPE:
this.cancelPick();
events.emit(this, "picker-accessible-canceled");
break;
case event.DOM_VK_C:
if (
(IS_OSX && event.metaKey && event.altKey) ||
(!IS_OSX && event.ctrlKey && event.shiftKey)
) {
this.cancelPick();
events.emit(this, "picker-accessible-canceled");
}
break;
default:
break;
}
}
/**
* Picker method that starts picker content listeners.
*/
pick() {
if (!this._isPicking) {
this._isPicking = true;
this._setPickerEnvironment();
}
}
/**
* This pick method also focuses the highlighter's target window.
*/
pickAndFocus() {
this.pick();
this.rootWin.focus();
}
attachAccessible(rawAccessible, accessibleDocument) {
// If raw accessible object is defunct or detached, no need to cache it and
// its ancestry.
if (
!rawAccessible ||
isDefunct(rawAccessible) ||
rawAccessible.indexInParent < 0
) {
return null;
}
const accessible = this.addRef(rawAccessible);
// There is a chance that ancestry lookup can fail if the accessible is in
// the detached subtree. At that point the root accessible object would be
// defunct and accessing it via parent property will throw.
try {
let parent = accessible;
while (parent && parent.rawAccessible != accessibleDocument) {
parent = parent.parentAcc;
}
} catch (error) {
throw new Error(`Failed to get ancestor for ${accessible}: ${error}`);
}
return accessible;
}
/**
* Find deepest accessible object that corresponds to the screen coordinates of the
* mouse pointer and attach it to the AccessibilityWalker tree.
*
* @param {Object} event
* Correspoinding content event.
* @return {null|Object}
* Accessible object, if available, that corresponds to a DOM node.
*/
_findAndAttachAccessible(event) {
const target = event.originalTarget || event.target;
const win = target.ownerGlobal;
// This event might be inside a sub-document, so don't use this.rootDoc.
const docAcc = this.getRawAccessibleFor(win.document);
// If the target is inside a pop-up widget, we need to query the pop-up
// Accessible, not the DocAccessible. The DocAccessible can't hit test
// inside pop-ups.
const popup = win.isChromeWindow ? target.closest("panel") : null;
const containerAcc = popup ? this.getRawAccessibleFor(popup) : docAcc;
const { devicePixelRatio } = this.rootWin;
const rawAccessible = containerAcc.getDeepestChildAtPointInProcess(
event.screenX * devicePixelRatio,
event.screenY * devicePixelRatio
);
return this.attachAccessible(rawAccessible, docAcc);
}
/**
* Start picker content listeners.
*/
_setPickerEnvironment() {
const target = this.targetActor.chromeEventHandler;
target.addEventListener("mousemove", this.onHovered, true);
target.addEventListener("click", this.onPick, true);
target.addEventListener("mousedown", this._preventContentEvent, true);
target.addEventListener("mouseup", this._preventContentEvent, true);
target.addEventListener("mouseover", this._preventContentEvent, true);
target.addEventListener("mouseout", this._preventContentEvent, true);
target.addEventListener("mouseleave", this._preventContentEvent, true);
target.addEventListener("mouseenter", this._preventContentEvent, true);
target.addEventListener("dblclick", this._preventContentEvent, true);
target.addEventListener("keydown", this.onKey, true);
target.addEventListener("keyup", this._preventContentEvent, true);
}
/**
* If content is still alive, stop picker content listeners, reset the hover state for
* last target element.
*/
_unsetPickerEnvironment() {
const target = this.targetActor.chromeEventHandler;
if (!target) {
return;
}
target.removeEventListener("mousemove", this.onHovered, true);
target.removeEventListener("click", this.onPick, true);
target.removeEventListener("mousedown", this._preventContentEvent, true);
target.removeEventListener("mouseup", this._preventContentEvent, true);
target.removeEventListener("mouseover", this._preventContentEvent, true);
target.removeEventListener("mouseout", this._preventContentEvent, true);
target.removeEventListener("mouseleave", this._preventContentEvent, true);
target.removeEventListener("mouseenter", this._preventContentEvent, true);
target.removeEventListener("dblclick", this._preventContentEvent, true);
target.removeEventListener("keydown", this.onKey, true);
target.removeEventListener("keyup", this._preventContentEvent, true);
this._resetStateAndReleaseTarget();
}
/**
* When using accessibility highlighter, we keep track of the most current event pointer
* event target. In order to update or release the target, we need to make sure we set
* the content state (using InspectorUtils) to its original value.
*
* TODO: This logic can be removed if/when we can use elementsAtPoint API for
* determining topmost DOMNode that corresponds to specific coordinates. We would then
* be able to use a highlighter overlay that would prevent all pointer events to content
* but still render highlighter for the node/element correctly.
*/
_resetStateAndReleaseTarget() {
if (!this._currentTarget) {
return;
}
try {
if (this._currentTargetHoverState) {
InspectorUtils.setContentState(this._currentTarget, kStateHover);
}
} catch (e) {
// DOMNode is already dead.
}
this._currentTarget = null;
this._currentTargetState = null;
}
/**
* Cacncel picker pick. Remvoe all content listeners and hide the highlighter.
*/
cancelPick() {
this.unhighlight();
if (this._isPicking) {
this._unsetPickerEnvironment();
this._isPicking = false;
this._currentAccessible = null;
}
}
/**
* Indicates that the tabbing order current active element (focused) is being
* tracked.
*/
_isTrackingTabbingOrderFocus = false;
/**
* Current focused element in the tabbing order.
*/
_currentFocusedTabbingOrder = null;
/**
* Focusin event handler for when interacting with tabbing order overlay.
*
* @param {Object} event
* Most recent focusin event.
*/
async onFocusIn(event) {
if (!this._isTrackingTabbingOrderFocus) {
return;
}
const target = event.originalTarget || event.target;
if (target === this._currentFocusedTabbingOrder) {
return;
}
this._currentFocusedTabbingOrder = target;
this.tabbingOrderHighlighter._highlighter.updateFocus({
node: target,
focused: true,
});
}
/**
* Focusout event handler for when interacting with tabbing order overlay.
*
* @param {Object} event
* Most recent focusout event.
*/
async onFocusOut(event) {
if (
!this._isTrackingTabbingOrderFocus ||
!this._currentFocusedTabbingOrder
) {
return;
}
const target = event.originalTarget || event.target;
// Sanity check.
if (target !== this._currentFocusedTabbingOrder) {
console.warn(
`focusout target: ${target} does not match current focused element in tabbing order: ${this._currentFocusedTabbingOrder}`
);
}
this.tabbingOrderHighlighter._highlighter.updateFocus({
node: this._currentFocusedTabbingOrder,
focused: false,
});
this._currentFocusedTabbingOrder = null;
}
/**
* Show tabbing order overlay for a given target.
*
* @param {Object} elm
* domnode actor to be used as the starting point for generating the
* tabbing order.
* @param {Number} index
* Starting index for the tabbing order.
*
* @return {JSON}
* Tabbing order information for the last element in the tabbing
* order. It includes a ContentDOMReference for the node and a tabbing
* index. If we are at the end of the tabbing order for the top level
* content document, the ContentDOMReference will be null. If focus
* manager discovered a remote IFRAME, then the ContentDOMReference
* references the IFRAME itself.
*/
showTabbingOrder(elm, index) {
// Start track focus related events (only once). `showTabbingOrder` will be
// called multiple times for a given target if it contains other remote
// targets.
if (!this._isTrackingTabbingOrderFocus) {
this._isTrackingTabbingOrderFocus = true;
const target = this.targetActor.chromeEventHandler;
target.addEventListener("focusin", this.onFocusIn, true);
target.addEventListener("focusout", this.onFocusOut, true);
}
return this.tabbingOrderHighlighter.show(elm, { index });
}
/**
* Hide tabbing order overlay for a given target.
*/
hideTabbingOrder() {
if (!this._tabbingOrderHighlighter) {
return;
}
this.tabbingOrderHighlighter.hide();
if (!this._isTrackingTabbingOrderFocus) {
return;
}
this._isTrackingTabbingOrderFocus = false;
this._currentFocusedTabbingOrder = null;
const target = this.targetActor.chromeEventHandler;
if (target) {
target.removeEventListener("focusin", this.onFocusIn, true);
target.removeEventListener("focusout", this.onFocusOut, true);
}
}
}
exports.AccessibleWalkerActor = AccessibleWalkerActor;