walker.js (38790B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { Actor } = require("resource://devtools/shared/protocol.js"); 8 const { 9 accessibleWalkerSpec, 10 } = require("resource://devtools/shared/specs/accessibility.js"); 11 12 const { 13 simulation: { COLOR_TRANSFORMATION_MATRICES }, 14 } = require("resource://devtools/server/actors/accessibility/constants.js"); 15 16 loader.lazyRequireGetter( 17 this, 18 "AccessibleActor", 19 "resource://devtools/server/actors/accessibility/accessible.js", 20 true 21 ); 22 loader.lazyRequireGetter( 23 this, 24 ["CustomHighlighterActor"], 25 "resource://devtools/server/actors/highlighters.js", 26 true 27 ); 28 loader.lazyRequireGetter( 29 this, 30 "DevToolsUtils", 31 "resource://devtools/shared/DevToolsUtils.js" 32 ); 33 loader.lazyRequireGetter( 34 this, 35 "events", 36 "resource://devtools/shared/event-emitter.js" 37 ); 38 loader.lazyRequireGetter( 39 this, 40 ["isWindowIncluded", "isFrameWithChildTarget"], 41 "resource://devtools/shared/layout/utils.js", 42 true 43 ); 44 loader.lazyRequireGetter( 45 this, 46 "isXUL", 47 "resource://devtools/server/actors/highlighters/utils/markup.js", 48 true 49 ); 50 loader.lazyRequireGetter( 51 this, 52 [ 53 "isDefunct", 54 "loadSheetForBackgroundCalculation", 55 "removeSheetForBackgroundCalculation", 56 ], 57 "resource://devtools/server/actors/utils/accessibility.js", 58 true 59 ); 60 loader.lazyRequireGetter( 61 this, 62 "accessibility", 63 "resource://devtools/shared/constants.js", 64 true 65 ); 66 67 const lazy = {}; 68 ChromeUtils.defineESModuleGetters( 69 lazy, 70 { 71 TYPES: "resource://devtools/shared/highlighters.mjs", 72 }, 73 { global: "contextual" } 74 ); 75 76 const kStateHover = 0x00000004; // ElementState::HOVER 77 78 const { 79 EVENT_TEXT_CHANGED, 80 EVENT_TEXT_INSERTED, 81 EVENT_TEXT_REMOVED, 82 EVENT_ACCELERATOR_CHANGE, 83 EVENT_ACTION_CHANGE, 84 EVENT_DEFACTION_CHANGE, 85 EVENT_DESCRIPTION_CHANGE, 86 EVENT_DOCUMENT_ATTRIBUTES_CHANGED, 87 EVENT_HIDE, 88 EVENT_NAME_CHANGE, 89 EVENT_OBJECT_ATTRIBUTE_CHANGED, 90 EVENT_REORDER, 91 EVENT_STATE_CHANGE, 92 EVENT_TEXT_ATTRIBUTE_CHANGED, 93 EVENT_VALUE_CHANGE, 94 } = Ci.nsIAccessibleEvent; 95 96 // TODO: We do not need this once bug 1422913 is fixed. We also would not need 97 // to fire a name change event for an accessible that has an updated subtree and 98 // that has its name calculated from the said subtree. 99 const NAME_FROM_SUBTREE_RULE_ROLES = new Set([ 100 Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWN, 101 Ci.nsIAccessibleRole.ROLE_BUTTONMENU, 102 Ci.nsIAccessibleRole.ROLE_CELL, 103 Ci.nsIAccessibleRole.ROLE_CHECKBUTTON, 104 Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM, 105 Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION, 106 Ci.nsIAccessibleRole.ROLE_COLUMNHEADER, 107 Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION, 108 Ci.nsIAccessibleRole.ROLE_DEFINITION, 109 Ci.nsIAccessibleRole.ROLE_GRID_CELL, 110 Ci.nsIAccessibleRole.ROLE_HEADING, 111 Ci.nsIAccessibleRole.ROLE_KEY, 112 Ci.nsIAccessibleRole.ROLE_LABEL, 113 Ci.nsIAccessibleRole.ROLE_LINK, 114 Ci.nsIAccessibleRole.ROLE_LISTITEM, 115 Ci.nsIAccessibleRole.ROLE_MATHML_IDENTIFIER, 116 Ci.nsIAccessibleRole.ROLE_MATHML_NUMBER, 117 Ci.nsIAccessibleRole.ROLE_MATHML_OPERATOR, 118 Ci.nsIAccessibleRole.ROLE_MATHML_TEXT, 119 Ci.nsIAccessibleRole.ROLE_MATHML_STRING_LITERAL, 120 Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH, 121 Ci.nsIAccessibleRole.ROLE_MENUITEM, 122 Ci.nsIAccessibleRole.ROLE_OPTION, 123 Ci.nsIAccessibleRole.ROLE_OUTLINEITEM, 124 Ci.nsIAccessibleRole.ROLE_PAGETAB, 125 Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM, 126 Ci.nsIAccessibleRole.ROLE_PUSHBUTTON, 127 Ci.nsIAccessibleRole.ROLE_RADIOBUTTON, 128 Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM, 129 Ci.nsIAccessibleRole.ROLE_RICH_OPTION, 130 Ci.nsIAccessibleRole.ROLE_ROW, 131 Ci.nsIAccessibleRole.ROLE_ROWHEADER, 132 Ci.nsIAccessibleRole.ROLE_SUMMARY, 133 Ci.nsIAccessibleRole.ROLE_SWITCH, 134 Ci.nsIAccessibleRole.ROLE_TERM, 135 Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON, 136 Ci.nsIAccessibleRole.ROLE_TOOLTIP, 137 ]); 138 139 const IS_OSX = Services.appinfo.OS === "Darwin"; 140 141 const { 142 SCORES: { BEST_PRACTICES, FAIL, WARNING }, 143 } = accessibility; 144 145 /** 146 * Helper function that determines if nsIAccessible object is in stale state. When an 147 * object is stale it means its subtree is not up to date. 148 * 149 * @param {nsIAccessible} accessible 150 * object to be tested. 151 * @return {boolean} 152 * True if accessible object is stale, false otherwise. 153 */ 154 function isStale(accessible) { 155 const extraState = {}; 156 accessible.getState({}, extraState); 157 // extraState.value is a bitmask. We are applying bitwise AND to mask out 158 // irrelevant states. 159 return !!(extraState.value & Ci.nsIAccessibleStates.EXT_STATE_STALE); 160 } 161 162 /** 163 * Get accessibility audit starting with the passed accessible object as a root. 164 * 165 * @param {object} acc 166 * AccessibileActor to be used as the root for the audit. 167 * @param {object} options 168 * Options for running audit, may include: 169 * - types: Array of audit types to be performed during audit. 170 * @param {Map} report 171 * An accumulator map to be used to store audit information. 172 * @param {object} progress 173 * An audit project object that is used to track the progress of the 174 * audit and send progress "audit-event" events to the client. 175 */ 176 function getAudit(acc, options, report, progress) { 177 if (acc.isDefunct) { 178 return; 179 } 180 181 // Audit returns a promise, save the actual value in the report. 182 report.set( 183 acc, 184 acc.audit(options).then(result => { 185 report.set(acc, result); 186 progress.increment(); 187 }) 188 ); 189 190 for (const child of acc.children()) { 191 getAudit(child, options, report, progress); 192 } 193 } 194 195 /** 196 * A helper class that is used to track audit progress and send progress events 197 * to the client. 198 */ 199 class AuditProgress { 200 constructor(walker) { 201 this.completed = 0; 202 this.percentage = 0; 203 this.walker = walker; 204 } 205 206 setTotal(size) { 207 this.size = size; 208 } 209 210 notify() { 211 this.walker.emit("audit-event", { 212 type: "progress", 213 progress: { 214 total: this.size, 215 percentage: this.percentage, 216 completed: this.completed, 217 }, 218 }); 219 } 220 221 increment() { 222 this.completed++; 223 const { completed, size } = this; 224 if (!size) { 225 return; 226 } 227 228 const percentage = Math.round((completed / size) * 100); 229 if (percentage > this.percentage) { 230 this.percentage = percentage; 231 this.notify(); 232 } 233 } 234 235 destroy() { 236 this.walker = null; 237 } 238 } 239 240 /** 241 * The AccessibleWalkerActor stores a cache of AccessibleActors that represent 242 * accessible objects in a given document. 243 * 244 * It is also responsible for implicitely initializing and shutting down 245 * accessibility engine by storing a reference to the XPCOM accessibility 246 * service. 247 */ 248 class AccessibleWalkerActor extends Actor { 249 constructor(conn, targetActor) { 250 super(conn, accessibleWalkerSpec); 251 this.targetActor = targetActor; 252 this.refMap = new Map(); 253 this._loadedSheets = new WeakMap(); 254 this.setA11yServiceGetter(); 255 this.onPick = this.onPick.bind(this); 256 this.onHovered = this.onHovered.bind(this); 257 this._preventContentEvent = this._preventContentEvent.bind(this); 258 this.onKey = this.onKey.bind(this); 259 this.onFocusIn = this.onFocusIn.bind(this); 260 this.onFocusOut = this.onFocusOut.bind(this); 261 this.onHighlighterEvent = this.onHighlighterEvent.bind(this); 262 } 263 264 get highlighter() { 265 if (!this._highlighter) { 266 this._highlighter = new CustomHighlighterActor( 267 this, 268 lazy.TYPES.ACCESSIBLE 269 ); 270 271 this.manage(this._highlighter); 272 this._highlighter.on("highlighter-event", this.onHighlighterEvent); 273 } 274 275 return this._highlighter; 276 } 277 278 get tabbingOrderHighlighter() { 279 if (!this._tabbingOrderHighlighter) { 280 this._tabbingOrderHighlighter = new CustomHighlighterActor( 281 this, 282 lazy.TYPES.TABBING_ORDER 283 ); 284 285 this.manage(this._tabbingOrderHighlighter); 286 } 287 288 return this._tabbingOrderHighlighter; 289 } 290 291 setA11yServiceGetter() { 292 DevToolsUtils.defineLazyGetter(this, "a11yService", () => { 293 Services.obs.addObserver(this, "accessible-event"); 294 return Cc["@mozilla.org/accessibilityService;1"].getService( 295 Ci.nsIAccessibilityService 296 ); 297 }); 298 } 299 300 get rootWin() { 301 return this.targetActor && this.targetActor.window; 302 } 303 304 get rootDoc() { 305 return this.targetActor && this.targetActor.window.document; 306 } 307 308 get isXUL() { 309 return isXUL(this.rootWin); 310 } 311 312 get colorMatrix() { 313 if (!this.targetActor.docShell) { 314 return null; 315 } 316 317 const colorMatrix = this.targetActor.docShell.getColorMatrix(); 318 if ( 319 colorMatrix.length === 0 || 320 colorMatrix === COLOR_TRANSFORMATION_MATRICES.NONE 321 ) { 322 return null; 323 } 324 325 return colorMatrix; 326 } 327 328 reset() { 329 try { 330 Services.obs.removeObserver(this, "accessible-event"); 331 } catch (e) { 332 // Accessible event observer might not have been initialized if a11y 333 // service was never used. 334 } 335 336 this.cancelPick(); 337 338 // Clean up accessible actors cache. 339 this.clearRefs(); 340 341 this._childrenPromise = null; 342 delete this.a11yService; 343 this.setA11yServiceGetter(); 344 } 345 346 /** 347 * Remove existing cache (of accessible actors) from tree. 348 */ 349 clearRefs() { 350 for (const actor of this.refMap.values()) { 351 actor.destroy(); 352 } 353 } 354 355 destroy() { 356 super.destroy(); 357 358 this.reset(); 359 360 if (this._highlighter) { 361 this._highlighter.off("highlighter-event", this.onHighlighterEvent); 362 this._highlighter = null; 363 } 364 365 if (this._tabbingOrderHighlighter) { 366 this._tabbingOrderHighlighter = null; 367 } 368 369 this.targetActor = null; 370 this.refMap = null; 371 } 372 373 getRef(rawAccessible) { 374 return this.refMap.get(rawAccessible); 375 } 376 377 addRef(rawAccessible) { 378 let actor = this.refMap.get(rawAccessible); 379 if (actor) { 380 return actor; 381 } 382 383 actor = new AccessibleActor(this, rawAccessible); 384 // Add the accessible actor as a child of this accessible walker actor, 385 // assigning it an actorID. 386 this.manage(actor); 387 this.refMap.set(rawAccessible, actor); 388 389 return actor; 390 } 391 392 /** 393 * Clean up accessible actors cache for a given accessible's subtree. 394 * 395 * @param {null|nsIAccessible} rawAccessible 396 */ 397 purgeSubtree(rawAccessible) { 398 if (!rawAccessible) { 399 return; 400 } 401 402 try { 403 for ( 404 let child = rawAccessible.firstChild; 405 child; 406 child = child.nextSibling 407 ) { 408 this.purgeSubtree(child); 409 } 410 } catch (e) { 411 // rawAccessible or its descendants are defunct. 412 } 413 414 const actor = this.getRef(rawAccessible); 415 if (actor) { 416 actor.destroy(); 417 } 418 } 419 420 unmanage(actor) { 421 if (actor instanceof AccessibleActor) { 422 this.refMap.delete(actor.rawAccessible); 423 } 424 Actor.prototype.unmanage.call(this, actor); 425 } 426 427 /** 428 * A helper method. Accessibility walker is assumed to have only 1 child which 429 * is the top level document. 430 */ 431 async children() { 432 if (this._childrenPromise) { 433 return this._childrenPromise; 434 } 435 436 this._childrenPromise = Promise.all([this.getDocument()]); 437 const children = await this._childrenPromise; 438 this._childrenPromise = null; 439 return children; 440 } 441 442 /** 443 * A promise for a root document accessible actor that only resolves when its 444 * corresponding document accessible object is fully loaded. 445 * 446 * @return {Promise} 447 */ 448 getDocument() { 449 if (!this.rootDoc || !this.rootDoc.documentElement) { 450 return this.once("document-ready").then(docAcc => this.addRef(docAcc)); 451 } 452 453 if (this.isXUL) { 454 const doc = this.addRef(this.getRawAccessibleFor(this.rootDoc)); 455 return Promise.resolve(doc); 456 } 457 458 const doc = this.getRawAccessibleFor(this.rootDoc); 459 460 // For non-visible same-process iframes we don't get a document and 461 // won't get a "document-ready" event. 462 if (!doc && !this.rootWin.windowGlobalChild.isProcessRoot) { 463 // We can ignore such document as there won't be anything to audit in them. 464 return null; 465 } 466 467 if (!doc || isStale(doc)) { 468 return this.once("document-ready").then(docAcc => this.addRef(docAcc)); 469 } 470 471 return Promise.resolve(this.addRef(doc)); 472 } 473 474 /** 475 * Get an accessible actor for a domnode actor. 476 * 477 * @param {object} domNode 478 * domnode actor for which accessible actor is being created. 479 * @return {Promse} 480 * A promise that resolves when accessible actor is created for a 481 * domnode actor. 482 */ 483 getAccessibleFor(domNode) { 484 // We need to make sure that the document is loaded processed by a11y first. 485 return this.getDocument().then(() => { 486 const rawAccessible = this.getRawAccessibleFor(domNode.rawNode); 487 // Not all DOM nodes have corresponding accessible objects. It's usually 488 // the case where there is no semantics or relevance to the accessibility 489 // client. 490 if (!rawAccessible) { 491 return null; 492 } 493 494 return this.addRef(rawAccessible); 495 }); 496 } 497 498 /** 499 * Get a raw accessible object for a raw node. 500 * 501 * @param {DOMNode} rawNode 502 * Raw node for which accessible object is being retrieved. 503 * @return {nsIAccessible} 504 * Accessible object for a given DOMNode. 505 */ 506 getRawAccessibleFor(rawNode) { 507 // Accessible can only be retrieved iff accessibility service is enabled. 508 if (!Services.appinfo.accessibilityEnabled) { 509 return null; 510 } 511 512 return this.a11yService.getAccessibleFor(rawNode); 513 } 514 515 async getAncestry(accessible) { 516 if (!accessible || accessible.indexInParent === -1) { 517 return []; 518 } 519 const doc = await this.getDocument(); 520 if (!doc) { 521 return []; 522 } 523 524 const ancestry = []; 525 if (accessible === doc) { 526 return ancestry; 527 } 528 529 try { 530 let parent = accessible; 531 while (parent && (parent = parent.parentAcc) && parent != doc) { 532 ancestry.push(parent); 533 } 534 ancestry.push(doc); 535 } catch (error) { 536 throw new Error(`Failed to get ancestor for ${accessible}: ${error}`); 537 } 538 539 return ancestry.map(parent => ({ 540 accessible: parent, 541 children: parent.children(), 542 })); 543 } 544 545 /** 546 * Run accessibility audit and return relevant ancestries for AccessibleActors 547 * that have non-empty audit checks. 548 * 549 * @param {object} options 550 * Options for running audit, may include: 551 * - types: Array of audit types to be performed during audit. 552 * 553 * @return {Promise} 554 * A promise that resolves when the audit is complete and all relevant 555 * ancestries are calculated. 556 */ 557 async audit(options) { 558 const doc = await this.getDocument(); 559 if (!doc) { 560 return []; 561 } 562 563 const report = new Map(); 564 this._auditProgress = new AuditProgress(this); 565 getAudit(doc, options, report, this._auditProgress); 566 this._auditProgress.setTotal(report.size); 567 await Promise.all(report.values()); 568 569 const ancestries = []; 570 for (const [acc, audit] of report.entries()) { 571 // Filter out audits that have no failing checks. 572 if ( 573 audit && 574 Object.values(audit).some( 575 check => 576 check != null && 577 !check.error && 578 [BEST_PRACTICES, FAIL, WARNING].includes(check.score) 579 ) 580 ) { 581 ancestries.push(this.getAncestry(acc)); 582 } 583 } 584 585 return Promise.all(ancestries); 586 } 587 588 /** 589 * Start accessibility audit. The result of this function will not be an audit 590 * report. Instead, an "audit-event" event will be fired when the audit is 591 * completed or fails. 592 * 593 * @param {object} options 594 * Options for running audit, may include: 595 * - types: Array of audit types to be performed during audit. 596 */ 597 startAudit(options) { 598 // Audit is already running, wait for the "audit-event" event. 599 if (this._auditing) { 600 return; 601 } 602 603 this._auditing = this.audit(options) 604 // We do not want to block on audit request, instead fire "audit-event" 605 // event when internal audit is finished or failed. 606 .then(ancestries => 607 this.emit("audit-event", { 608 type: "completed", 609 ancestries, 610 }) 611 ) 612 .catch(() => this.emit("audit-event", { type: "error" })) 613 .finally(() => { 614 this._auditing = null; 615 if (this._auditProgress) { 616 this._auditProgress.destroy(); 617 this._auditProgress = null; 618 } 619 }); 620 } 621 622 onHighlighterEvent(data) { 623 this.emit("highlighter-event", data); 624 } 625 626 /** 627 * Accessible event observer function. 628 * 629 * @param {Ci.nsIAccessibleEvent} subject 630 * accessible event object. 631 */ 632 // eslint-disable-next-line complexity 633 observe(subject) { 634 const event = subject.QueryInterface(Ci.nsIAccessibleEvent); 635 const rawAccessible = event.accessible; 636 const accessible = this.getRef(rawAccessible); 637 638 if (rawAccessible instanceof Ci.nsIAccessibleDocument && !accessible) { 639 const rootDocAcc = this.getRawAccessibleFor(this.rootDoc); 640 if (rawAccessible === rootDocAcc && !isStale(rawAccessible)) { 641 this.clearRefs(); 642 // If it's a top level document notify listeners about the document 643 // being ready. 644 this.emit("document-ready", rawAccessible); 645 } 646 } 647 648 switch (event.eventType) { 649 case EVENT_STATE_CHANGE: { 650 const { state, isEnabled } = event.QueryInterface( 651 Ci.nsIAccessibleStateChangeEvent 652 ); 653 const isBusy = state & Ci.nsIAccessibleStates.STATE_BUSY; 654 if (accessible) { 655 // Only propagate state change events for active accessibles. 656 if (isBusy && isEnabled) { 657 if (rawAccessible instanceof Ci.nsIAccessibleDocument) { 658 // Remove existing cache from tree. 659 this.clearRefs(); 660 } 661 return; 662 } 663 accessible.emit("states-change", accessible.states); 664 } 665 666 break; 667 } 668 case EVENT_NAME_CHANGE: 669 if (accessible) { 670 accessible.emit( 671 "name-change", 672 rawAccessible.name, 673 event.DOMNode == this.rootDoc 674 ? undefined 675 : this.getRef(rawAccessible.parent) 676 ); 677 } 678 break; 679 case EVENT_VALUE_CHANGE: 680 if (accessible) { 681 accessible.emit("value-change", rawAccessible.value); 682 } 683 break; 684 case EVENT_DESCRIPTION_CHANGE: 685 if (accessible) { 686 accessible.emit("description-change", rawAccessible.description); 687 } 688 break; 689 case EVENT_REORDER: 690 if (accessible) { 691 accessible 692 .children() 693 .forEach(child => 694 child.emit("index-in-parent-change", child.indexInParent) 695 ); 696 accessible.emit("reorder", rawAccessible.childCount); 697 } 698 break; 699 case EVENT_HIDE: 700 if (event.DOMNode == this.rootDoc) { 701 this.clearRefs(); 702 } else { 703 this.purgeSubtree(rawAccessible); 704 } 705 break; 706 case EVENT_DEFACTION_CHANGE: 707 case EVENT_ACTION_CHANGE: 708 if (accessible) { 709 accessible.emit("actions-change", accessible.actions); 710 } 711 break; 712 case EVENT_TEXT_CHANGED: 713 case EVENT_TEXT_INSERTED: 714 case EVENT_TEXT_REMOVED: 715 if (accessible) { 716 accessible.emit("text-change"); 717 if (NAME_FROM_SUBTREE_RULE_ROLES.has(rawAccessible.role)) { 718 accessible.emit( 719 "name-change", 720 rawAccessible.name, 721 event.DOMNode == this.rootDoc 722 ? undefined 723 : this.getRef(rawAccessible.parent) 724 ); 725 } 726 } 727 break; 728 case EVENT_DOCUMENT_ATTRIBUTES_CHANGED: 729 case EVENT_OBJECT_ATTRIBUTE_CHANGED: 730 case EVENT_TEXT_ATTRIBUTE_CHANGED: 731 if (accessible) { 732 accessible.emit("attributes-change", accessible.attributes); 733 } 734 break; 735 // EVENT_ACCELERATOR_CHANGE is currently not fired by gecko accessibility. 736 case EVENT_ACCELERATOR_CHANGE: 737 if (accessible) { 738 accessible.emit("shortcut-change", accessible.keyboardShortcut); 739 } 740 break; 741 default: 742 break; 743 } 744 } 745 746 /** 747 * Ensure that nothing interferes with the audit for an accessible object 748 * (CSS, overlays) by load accessibility highlighter style sheet used for 749 * preventing transitions and applying transparency when calculating colour 750 * contrast as well as temporarily hiding accessible highlighter overlay. 751 * 752 * @param {object} win 753 * Window where highlighting happens. 754 */ 755 async clearStyles(win) { 756 const requests = this._loadedSheets.get(win); 757 if (requests != null) { 758 this._loadedSheets.set(win, requests + 1); 759 return; 760 } 761 762 // Disable potential mouse driven transitions (This is important because accessibility 763 // highlighter temporarily modifies text color related CSS properties. In case where 764 // there are transitions that affect them, there might be unexpected side effects when 765 // taking a snapshot for contrast measurement). 766 loadSheetForBackgroundCalculation(win); 767 this._loadedSheets.set(win, 1); 768 await this.hideHighlighter(); 769 } 770 771 /** 772 * Restore CSS and overlays that could've interfered with the audit for an 773 * accessible object by unloading accessibility highlighter style sheet used 774 * for preventing transitions and applying transparency when calculating 775 * colour contrast and potentially restoring accessible highlighter overlay. 776 * 777 * @param {object} win 778 * Window where highlighting was happenning. 779 */ 780 async restoreStyles(win) { 781 const requests = this._loadedSheets.get(win); 782 if (!requests) { 783 return; 784 } 785 786 if (requests > 1) { 787 this._loadedSheets.set(win, requests - 1); 788 return; 789 } 790 791 await this.showHighlighter(); 792 removeSheetForBackgroundCalculation(win); 793 this._loadedSheets.delete(win); 794 } 795 796 async hideHighlighter() { 797 // TODO: Fix this workaround that temporarily removes higlighter bounds 798 // overlay that can interfere with the contrast ratio calculation. 799 if (this._highlighter) { 800 const highlighter = this._highlighter.instance; 801 await highlighter.isReady; 802 highlighter.hideAccessibleBounds(); 803 } 804 } 805 806 async showHighlighter() { 807 // TODO: Fix this workaround that temporarily removes higlighter bounds 808 // overlay that can interfere with the contrast ratio calculation. 809 if (this._highlighter) { 810 const highlighter = this._highlighter.instance; 811 await highlighter.isReady; 812 highlighter.showAccessibleBounds(); 813 } 814 } 815 816 /** 817 * Public method used to show an accessible object highlighter on the client 818 * side. 819 * 820 * @param {object} accessible 821 * AccessibleActor to be highlighted. 822 * @param {object} options 823 * Object used for passing options. Available options: 824 * - duration {Number} 825 * Duration of time that the highlighter should be shown. 826 * @return {boolean} 827 * True if highlighter shows the accessible object. 828 */ 829 async highlightAccessible(accessible, options = {}) { 830 this.unhighlight(); 831 // Do not highlight if accessible is dead. 832 if (!accessible || accessible.isDefunct || accessible.indexInParent < 0) { 833 return false; 834 } 835 836 this._highlightingAccessible = accessible; 837 const { bounds } = accessible; 838 if (!bounds) { 839 return false; 840 } 841 842 const { DOMNode: rawNode } = accessible.rawAccessible; 843 const audit = await accessible.audit(); 844 if (this._highlightingAccessible !== accessible) { 845 return false; 846 } 847 848 const { name, role } = accessible; 849 const { highlighter } = this; 850 await highlighter.instance.isReady; 851 if (this._highlightingAccessible !== accessible) { 852 return false; 853 } 854 855 const shown = highlighter.show( 856 { rawNode }, 857 { ...options, ...bounds, name, role, audit, isXUL: this.isXUL } 858 ); 859 this._highlightingAccessible = null; 860 861 return shown; 862 } 863 864 /** 865 * Public method used to hide an accessible object highlighter on the client 866 * side. 867 */ 868 unhighlight() { 869 if (!this._highlighter) { 870 return; 871 } 872 873 this.highlighter.hide(); 874 this._highlightingAccessible = null; 875 } 876 877 /** 878 * Picking state that indicates if picking is currently enabled and, if so, 879 * what the current and hovered accessible objects are. 880 */ 881 _isPicking = false; 882 _currentAccessible = null; 883 884 /** 885 * Check is event handling is allowed. 886 */ 887 _isEventAllowed({ view }) { 888 return this.rootWin.isChromeWindow || isWindowIncluded(this.rootWin, view); 889 } 890 891 /** 892 * Check if the DOM event received when picking shold be ignored. 893 * 894 * @param {Event} event 895 */ 896 _ignoreEventWhenPicking(event) { 897 return ( 898 !this._isPicking || 899 // If the DOM event is about a remote frame, only the WalkerActor for that 900 // remote frame target should emit RDP events (hovered/picked/...). And 901 // all other WalkerActor for intermediate iframe and top level document 902 // targets should stay silent. 903 isFrameWithChildTarget( 904 this.targetActor, 905 event.originalTarget || event.target 906 ) 907 ); 908 } 909 910 _preventContentEvent(event) { 911 if (this._ignoreEventWhenPicking(event)) { 912 return; 913 } 914 915 event.stopPropagation(); 916 event.preventDefault(); 917 918 const target = event.originalTarget || event.target; 919 if (target !== this._currentTarget) { 920 this._resetStateAndReleaseTarget(); 921 this._currentTarget = target; 922 // We use InspectorUtils to save the original hover content state of the target 923 // element (that includes its hover state). In order to not trigger any visual 924 // changes to the element that depend on its hover state we remove the state while 925 // the element is the most current target of the highlighter. 926 // 927 // TODO: This logic can be removed if/when we can use elementsAtPoint API for 928 // determining topmost DOMNode that corresponds to specific coordinates. We would 929 // then be able to use a highlighter overlay that would prevent all pointer events 930 // to content but still render highlighter for the node/element correctly. 931 this._currentTargetHoverState = 932 InspectorUtils.getContentState(target) & kStateHover; 933 InspectorUtils.removeContentState(target, kStateHover); 934 } 935 } 936 937 /** 938 * Click event handler for when picking is enabled. 939 * 940 * @param {object} event 941 * Current click event. 942 */ 943 onPick(event) { 944 if (this._ignoreEventWhenPicking(event)) { 945 return; 946 } 947 948 this._preventContentEvent(event); 949 if (!this._isEventAllowed(event)) { 950 return; 951 } 952 953 // If shift is pressed, this is only a preview click, send the event to 954 // the client, but don't stop picking. 955 if (event.shiftKey) { 956 if (!this._currentAccessible) { 957 this._currentAccessible = this._findAndAttachAccessible(event); 958 } 959 this.emit("picker-accessible-previewed", this._currentAccessible); 960 return; 961 } 962 963 this._unsetPickerEnvironment(); 964 this._isPicking = false; 965 if (!this._currentAccessible) { 966 this._currentAccessible = this._findAndAttachAccessible(event); 967 } 968 this.emit("picker-accessible-picked", this._currentAccessible); 969 } 970 971 /** 972 * Hover event handler for when picking is enabled. 973 * 974 * @param {object} event 975 * Current hover event. 976 */ 977 async onHovered(event) { 978 if (this._ignoreEventWhenPicking(event)) { 979 return; 980 } 981 982 this._preventContentEvent(event); 983 if (!this._isEventAllowed(event)) { 984 return; 985 } 986 987 const accessible = this._findAndAttachAccessible(event); 988 if (!accessible || this._currentAccessible === accessible) { 989 return; 990 } 991 992 this._currentAccessible = accessible; 993 // Highlight current accessible and by the time we are done, if accessible that was 994 // highlighted is not current any more (user moved the mouse to a new node) highlight 995 // the most current accessible again. 996 const shown = await this.highlightAccessible(accessible); 997 if (this._isPicking && shown && accessible === this._currentAccessible) { 998 this.emit("picker-accessible-hovered", accessible); 999 } 1000 } 1001 1002 /** 1003 * Keyboard event handler for when picking is enabled. 1004 * 1005 * @param {object} event 1006 * Current keyboard event. 1007 */ 1008 onKey(event) { 1009 if (!this._currentAccessible || this._ignoreEventWhenPicking(event)) { 1010 return; 1011 } 1012 1013 this._preventContentEvent(event); 1014 if (!this._isEventAllowed(event)) { 1015 return; 1016 } 1017 1018 /** 1019 * KEY: Action/scope 1020 * ENTER/CARRIAGE_RETURN: Picks current accessible 1021 * ESC/CTRL+SHIFT+C: Cancels picker 1022 */ 1023 switch (event.keyCode) { 1024 // Select the element. 1025 case event.DOM_VK_RETURN: 1026 this.onPick(event); 1027 break; 1028 // Cancel pick mode. 1029 case event.DOM_VK_ESCAPE: 1030 this.cancelPick(); 1031 this.emit("picker-accessible-canceled"); 1032 break; 1033 case event.DOM_VK_C: 1034 if ( 1035 (IS_OSX && event.metaKey && event.altKey) || 1036 (!IS_OSX && event.ctrlKey && event.shiftKey) 1037 ) { 1038 this.cancelPick(); 1039 this.emit("picker-accessible-canceled"); 1040 } 1041 break; 1042 default: 1043 break; 1044 } 1045 } 1046 1047 /** 1048 * Picker method that starts picker content listeners. 1049 */ 1050 pick() { 1051 if (!this._isPicking) { 1052 this._isPicking = true; 1053 this._setPickerEnvironment(); 1054 } 1055 } 1056 1057 /** 1058 * This pick method also focuses the highlighter's target window. 1059 */ 1060 pickAndFocus() { 1061 this.pick(); 1062 this.rootWin.focus(); 1063 } 1064 1065 attachAccessible(rawAccessible, accessibleDocument) { 1066 // If raw accessible object is defunct or detached, no need to cache it and 1067 // its ancestry. 1068 if ( 1069 !rawAccessible || 1070 isDefunct(rawAccessible) || 1071 rawAccessible.indexInParent < 0 1072 ) { 1073 return null; 1074 } 1075 1076 const accessible = this.addRef(rawAccessible); 1077 // There is a chance that ancestry lookup can fail if the accessible is in 1078 // the detached subtree. At that point the root accessible object would be 1079 // defunct and accessing it via parent property will throw. 1080 try { 1081 let parent = accessible; 1082 while (parent && parent.rawAccessible != accessibleDocument) { 1083 parent = parent.parentAcc; 1084 } 1085 } catch (error) { 1086 throw new Error(`Failed to get ancestor for ${accessible}: ${error}`); 1087 } 1088 1089 return accessible; 1090 } 1091 1092 /** 1093 * Find deepest accessible object that corresponds to the screen coordinates of the 1094 * mouse pointer and attach it to the AccessibilityWalker tree. 1095 * 1096 * @param {object} event 1097 * Correspoinding content event. 1098 * @return {null | object} 1099 * Accessible object, if available, that corresponds to a DOM node. 1100 */ 1101 _findAndAttachAccessible(event) { 1102 const target = event.originalTarget || event.target; 1103 const win = target.ownerGlobal; 1104 // This event might be inside a sub-document, so don't use this.rootDoc. 1105 const docAcc = this.getRawAccessibleFor(win.document); 1106 // If the target is inside a pop-up widget, we need to query the pop-up 1107 // Accessible, not the DocAccessible. The DocAccessible can't hit test 1108 // inside pop-ups. 1109 const popup = win.isChromeWindow 1110 ? target.closest("panel, menupopup") 1111 : null; 1112 const containerAcc = popup ? this.getRawAccessibleFor(popup) : docAcc; 1113 const { devicePixelRatio } = this.rootWin; 1114 const rawAccessible = containerAcc.getDeepestChildAtPointInProcess( 1115 event.screenX * devicePixelRatio, 1116 event.screenY * devicePixelRatio 1117 ); 1118 return this.attachAccessible(rawAccessible, docAcc); 1119 } 1120 1121 /** 1122 * Start picker content listeners. 1123 */ 1124 _setPickerEnvironment() { 1125 const target = this.targetActor.chromeEventHandler; 1126 target.addEventListener("mousemove", this.onHovered, true); 1127 target.addEventListener("click", this.onPick, true); 1128 target.addEventListener("mousedown", this._preventContentEvent, true); 1129 target.addEventListener("mouseup", this._preventContentEvent, true); 1130 target.addEventListener("mouseover", this._preventContentEvent, true); 1131 target.addEventListener("mouseout", this._preventContentEvent, true); 1132 target.addEventListener("mouseleave", this._preventContentEvent, true); 1133 target.addEventListener("mouseenter", this._preventContentEvent, true); 1134 target.addEventListener("dblclick", this._preventContentEvent, true); 1135 target.addEventListener("keydown", this.onKey, true); 1136 target.addEventListener("keyup", this._preventContentEvent, true); 1137 } 1138 1139 /** 1140 * If content is still alive, stop picker content listeners, reset the hover state for 1141 * last target element. 1142 */ 1143 _unsetPickerEnvironment() { 1144 const target = this.targetActor.chromeEventHandler; 1145 1146 if (!target) { 1147 return; 1148 } 1149 1150 target.removeEventListener("mousemove", this.onHovered, true); 1151 target.removeEventListener("click", this.onPick, true); 1152 target.removeEventListener("mousedown", this._preventContentEvent, true); 1153 target.removeEventListener("mouseup", this._preventContentEvent, true); 1154 target.removeEventListener("mouseover", this._preventContentEvent, true); 1155 target.removeEventListener("mouseout", this._preventContentEvent, true); 1156 target.removeEventListener("mouseleave", this._preventContentEvent, true); 1157 target.removeEventListener("mouseenter", this._preventContentEvent, true); 1158 target.removeEventListener("dblclick", this._preventContentEvent, true); 1159 target.removeEventListener("keydown", this.onKey, true); 1160 target.removeEventListener("keyup", this._preventContentEvent, true); 1161 1162 this._resetStateAndReleaseTarget(); 1163 } 1164 1165 /** 1166 * When using accessibility highlighter, we keep track of the most current event pointer 1167 * event target. In order to update or release the target, we need to make sure we set 1168 * the content state (using InspectorUtils) to its original value. 1169 * 1170 * TODO: This logic can be removed if/when we can use elementsAtPoint API for 1171 * determining topmost DOMNode that corresponds to specific coordinates. We would then 1172 * be able to use a highlighter overlay that would prevent all pointer events to content 1173 * but still render highlighter for the node/element correctly. 1174 */ 1175 _resetStateAndReleaseTarget() { 1176 if (!this._currentTarget) { 1177 return; 1178 } 1179 1180 try { 1181 if (this._currentTargetHoverState) { 1182 InspectorUtils.setContentState(this._currentTarget, kStateHover); 1183 } 1184 } catch (e) { 1185 // DOMNode is already dead. 1186 } 1187 1188 this._currentTarget = null; 1189 this._currentTargetState = null; 1190 } 1191 1192 /** 1193 * Cacncel picker pick. Remvoe all content listeners and hide the highlighter. 1194 */ 1195 cancelPick() { 1196 this.unhighlight(); 1197 1198 if (this._isPicking) { 1199 this._unsetPickerEnvironment(); 1200 this._isPicking = false; 1201 this._currentAccessible = null; 1202 } 1203 } 1204 1205 /** 1206 * Indicates that the tabbing order current active element (focused) is being 1207 * tracked. 1208 */ 1209 _isTrackingTabbingOrderFocus = false; 1210 1211 /** 1212 * Current focused element in the tabbing order. 1213 */ 1214 _currentFocusedTabbingOrder = null; 1215 1216 /** 1217 * Focusin event handler for when interacting with tabbing order overlay. 1218 * 1219 * @param {object} event 1220 * Most recent focusin event. 1221 */ 1222 async onFocusIn(event) { 1223 if (!this._isTrackingTabbingOrderFocus) { 1224 return; 1225 } 1226 1227 const target = event.originalTarget || event.target; 1228 if (target === this._currentFocusedTabbingOrder) { 1229 return; 1230 } 1231 1232 this._currentFocusedTabbingOrder = target; 1233 this.tabbingOrderHighlighter._highlighter.updateFocus({ 1234 node: target, 1235 focused: true, 1236 }); 1237 } 1238 1239 /** 1240 * Focusout event handler for when interacting with tabbing order overlay. 1241 * 1242 * @param {object} event 1243 * Most recent focusout event. 1244 */ 1245 async onFocusOut(event) { 1246 if ( 1247 !this._isTrackingTabbingOrderFocus || 1248 !this._currentFocusedTabbingOrder 1249 ) { 1250 return; 1251 } 1252 1253 const target = event.originalTarget || event.target; 1254 // Sanity check. 1255 if (target !== this._currentFocusedTabbingOrder) { 1256 console.warn( 1257 `focusout target: ${target} does not match current focused element in tabbing order: ${this._currentFocusedTabbingOrder}` 1258 ); 1259 } 1260 1261 this.tabbingOrderHighlighter._highlighter.updateFocus({ 1262 node: this._currentFocusedTabbingOrder, 1263 focused: false, 1264 }); 1265 this._currentFocusedTabbingOrder = null; 1266 } 1267 1268 /** 1269 * Show tabbing order overlay for a given target. 1270 * 1271 * @param {object} elm 1272 * domnode actor to be used as the starting point for generating the 1273 * tabbing order. 1274 * @param {number} index 1275 * Starting index for the tabbing order. 1276 * 1277 * @return {JSON} 1278 * Tabbing order information for the last element in the tabbing 1279 * order. It includes a ContentDOMReference for the node and a tabbing 1280 * index. If we are at the end of the tabbing order for the top level 1281 * content document, the ContentDOMReference will be null. If focus 1282 * manager discovered a remote IFRAME, then the ContentDOMReference 1283 * references the IFRAME itself. 1284 */ 1285 showTabbingOrder(elm, index) { 1286 // Start track focus related events (only once). `showTabbingOrder` will be 1287 // called multiple times for a given target if it contains other remote 1288 // targets. 1289 if (!this._isTrackingTabbingOrderFocus) { 1290 this._isTrackingTabbingOrderFocus = true; 1291 const target = this.targetActor.chromeEventHandler; 1292 target.addEventListener("focusin", this.onFocusIn, true); 1293 target.addEventListener("focusout", this.onFocusOut, true); 1294 } 1295 1296 return this.tabbingOrderHighlighter.show(elm, { index }); 1297 } 1298 1299 /** 1300 * Hide tabbing order overlay for a given target. 1301 */ 1302 hideTabbingOrder() { 1303 if (!this._tabbingOrderHighlighter) { 1304 return; 1305 } 1306 1307 this.tabbingOrderHighlighter.hide(); 1308 if (!this._isTrackingTabbingOrderFocus) { 1309 return; 1310 } 1311 1312 this._isTrackingTabbingOrderFocus = false; 1313 this._currentFocusedTabbingOrder = null; 1314 const target = this.targetActor.chromeEventHandler; 1315 if (target) { 1316 target.removeEventListener("focusin", this.onFocusIn, true); 1317 target.removeEventListener("focusout", this.onFocusOut, true); 1318 } 1319 } 1320 } 1321 1322 exports.AccessibleWalkerActor = AccessibleWalkerActor;