/* 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,
} = require(
"resource://devtools/shared/specs/accessibility.js");
const {
simulation: { COLOR_TRANSFORMATION_MATRICES },
} = require(
"resource://devtools/server/actors/accessibility/constants.js");
loader.lazyRequireGetter(
this,
"AccessibleActor",
"resource://devtools/server/actors/accessibility/accessible.js",
true
);
loader.lazyRequireGetter(
this,
[
"CustomHighlighterActor"],
"resource://devtools/server/actors/highlighters.js",
true
);
loader.lazyRequireGetter(
this,
"DevToolsUtils",
"resource://devtools/shared/DevToolsUtils.js"
);
loader.lazyRequireGetter(
this,
"events",
"resource://devtools/shared/event-emitter.js"
);
loader.lazyRequireGetter(
this,
[
"isWindowIncluded",
"isFrameWithChildTarget"],
"resource://devtools/shared/layout/utils.js",
true
);
loader.lazyRequireGetter(
this,
"isXUL",
"resource://devtools/server/actors/highlighters/utils/markup.js",
true
);
loader.lazyRequireGetter(
this,
[
"isDefunct",
"loadSheetForBackgroundCalculation",
"removeSheetForBackgroundCalculation",
],
"resource://devtools/server/actors/utils/accessibility.js",
true
);
loader.lazyRequireGetter(
this,
"accessibility",
"resource://devtools/shared/constants.js",
true
);
const lazy = {};
ChromeUtils.defineESModuleGetters(
lazy,
{
TYPES:
"resource://devtools/shared/highlighters.mjs",
},
{ global:
"contextual" }
);
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,
lazy.TYPES.ACCESSIBLE
);
this.manage(
this._highlighter);
this._highlighter.on(
"highlighter-event",
this.onHighlighterEvent);
}
return this._highlighter;
}
get tabbingOrderHighlighter() {
if (!
this._tabbingOrderHighlighter) {
this._tabbingOrderHighlighter =
new CustomHighlighterActor(
this,
lazy.TYPES.TABBING_ORDER
);
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;