/* 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 Ci = Components.interfaces; const Cu = Components.utils; const TEXT_NODE = 3; Cu.import('resource://gre/modules/XPCOMUtils.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'Services', 'resource://gre/modules/Services.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'Utils', 'resource://gre/modules/accessibility/Utils.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'Logger', 'resource://gre/modules/accessibility/Utils.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'Presentation', 'resource://gre/modules/accessibility/Presentation.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'Roles', 'resource://gre/modules/accessibility/Constants.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'Events', 'resource://gre/modules/accessibility/Constants.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'States', 'resource://gre/modules/accessibility/Constants.jsm'); this.EXPORTED_SYMBOLS = ['EventManager']; this.EventManager = function EventManager(aContentScope, aContentControl) { this.contentScope = aContentScope; this.contentControl = aContentControl; this.addEventListener = this.contentScope.addEventListener.bind( this.contentScope); this.removeEventListener = this.contentScope.removeEventListener.bind( this.contentScope); this.sendMsgFunc = this.contentScope.sendAsyncMessage.bind( this.contentScope); this.webProgress = this.contentScope.docShell. QueryInterface(Ci.nsIInterfaceRequestor). getInterface(Ci.nsIWebProgress); }; this.EventManager.prototype = { editState: { editing: false }, start: function start() { try { if (!this._started) { Logger.debug('EventManager.start'); this._started = true; AccessibilityEventObserver.addListener(this); this.webProgress.addProgressListener(this, (Ci.nsIWebProgress.NOTIFY_STATE_ALL | Ci.nsIWebProgress.NOTIFY_LOCATION)); this.addEventListener('wheel', this, true); this.addEventListener('scroll', this, true); this.addEventListener('resize', this, true); this._preDialogPosition = new WeakMap(); } this.present(Presentation.tabStateChanged(null, 'newtab')); } catch (x) { Logger.logException(x, 'Failed to start EventManager'); } }, // XXX: Stop is not called when the tab is closed (|TabClose| event is too // late). It is only called when the AccessFu is disabled explicitly. stop: function stop() { if (!this._started) { return; } Logger.debug('EventManager.stop'); AccessibilityEventObserver.removeListener(this); try { this._preDialogPosition = new WeakMap(); this.webProgress.removeProgressListener(this); this.removeEventListener('wheel', this, true); this.removeEventListener('scroll', this, true); this.removeEventListener('resize', this, true); } catch (x) { // contentScope is dead. } finally { this._started = false; } }, handleEvent: function handleEvent(aEvent) { Logger.debug(() => { return ['DOMEvent', aEvent.type]; }); try { switch (aEvent.type) { case 'wheel': { let attempts = 0; let delta = aEvent.deltaX || aEvent.deltaY; this.contentControl.autoMove( null, { moveMethod: delta > 0 ? 'moveNext' : 'movePrevious', onScreenOnly: true, noOpIfOnScreen: true, delay: 500 }); break; } case 'scroll': case 'resize': { // the target could be an element, document or window let window = null; if (aEvent.target instanceof Ci.nsIDOMWindow) window = aEvent.target; else if (aEvent.target instanceof Ci.nsIDOMDocument) window = aEvent.target.defaultView; else if (aEvent.target instanceof Ci.nsIDOMElement) window = aEvent.target.ownerDocument.defaultView; this.present(Presentation.viewportChanged(window)); break; } } } catch (x) { Logger.logException(x, 'Error handling DOM event'); } }, handleAccEvent: function handleAccEvent(aEvent) { Logger.debug(() => { return ['A11yEvent', Logger.eventToString(aEvent), Logger.accessibleToString(aEvent.accessible)]; }); // Don't bother with non-content events in firefox. if (Utils.MozBuildApp == 'browser' && aEvent.eventType != Events.VIRTUALCURSOR_CHANGED && // XXX Bug 442005 results in DocAccessible::getDocType returning // NS_ERROR_FAILURE. Checking for aEvent.accessibleDocument.docType == // 'window' does not currently work. (aEvent.accessibleDocument.DOMDocument.doctype && aEvent.accessibleDocument.DOMDocument.doctype.name === 'window')) { return; } switch (aEvent.eventType) { case Events.VIRTUALCURSOR_CHANGED: { let pivot = aEvent.accessible. QueryInterface(Ci.nsIAccessibleDocument).virtualCursor; let position = pivot.position; if (position && position.role == Roles.INTERNAL_FRAME) break; let event = aEvent. QueryInterface(Ci.nsIAccessibleVirtualCursorChangeEvent); let reason = event.reason; let oldAccessible = event.oldAccessible; if (this.editState.editing && !Utils.getState(position).contains(States.FOCUSED)) { aEvent.accessibleDocument.takeFocus(); } this.present( Presentation.pivotChanged(position, oldAccessible, reason, pivot.startOffset, pivot.endOffset, aEvent.isFromUserInput)); break; } case Events.STATE_CHANGE: { let event = aEvent.QueryInterface(Ci.nsIAccessibleStateChangeEvent); let state = Utils.getState(event); if (state.contains(States.CHECKED)) { if (aEvent.accessible.role === Roles.SWITCH) { this.present( Presentation. actionInvoked(aEvent.accessible, event.isEnabled ? 'on' : 'off')); } else { this.present( Presentation. actionInvoked(aEvent.accessible, event.isEnabled ? 'check' : 'uncheck')); } } else if (state.contains(States.SELECTED)) { this.present( Presentation. actionInvoked(aEvent.accessible, event.isEnabled ? 'select' : 'unselect')); } break; } case Events.NAME_CHANGE: { let acc = aEvent.accessible; if (acc === this.contentControl.vc.position) { this.present(Presentation.nameChanged(acc)); } else { let {liveRegion, isPolite} = this._handleLiveRegion(aEvent, ['text', 'all']); if (liveRegion) { this.present(Presentation.nameChanged(acc, isPolite)); } } break; } case Events.SCROLLING_START: { this.contentControl.autoMove(aEvent.accessible); break; } case Events.TEXT_CARET_MOVED: { let acc = aEvent.accessible.QueryInterface(Ci.nsIAccessibleText); let caretOffset = aEvent. QueryInterface(Ci.nsIAccessibleCaretMoveEvent).caretOffset; // We could get a caret move in an accessible that is not focused, // it doesn't mean we are not on any editable accessible. just not // on this one.. let state = Utils.getState(acc); if (state.contains(States.FOCUSED)) { this._setEditingMode(aEvent, caretOffset); if (state.contains(States.EDITABLE)) { this.present(Presentation.textSelectionChanged(acc.getText(0, -1), caretOffset, caretOffset, 0, 0, aEvent.isFromUserInput)); } } break; } case Events.OBJECT_ATTRIBUTE_CHANGED: { let evt = aEvent.QueryInterface( Ci.nsIAccessibleObjectAttributeChangedEvent); if (evt.changedAttribute.toString() !== 'aria-hidden') { // Only handle aria-hidden attribute change. break; } let hidden = Utils.isHidden(aEvent.accessible); this[hidden ? '_handleHide' : '_handleShow'](evt); if (this.inTest) { this.sendMsgFunc("AccessFu:AriaHidden", { hidden: hidden }); } break; } case Events.SHOW: { this._handleShow(aEvent); break; } case Events.HIDE: { let evt = aEvent.QueryInterface(Ci.nsIAccessibleHideEvent); this._handleHide(evt); break; } case Events.TEXT_INSERTED: case Events.TEXT_REMOVED: { let {liveRegion, isPolite} = this._handleLiveRegion(aEvent, ['text', 'all']); if (aEvent.isFromUserInput || liveRegion) { // Handle all text mutations coming from the user or if they happen // on a live region. this._handleText(aEvent, liveRegion, isPolite); } break; } case Events.FOCUS: { // Put vc where the focus is at let acc = aEvent.accessible; let doc = aEvent.accessibleDocument; this._setEditingMode(aEvent); if ([Roles.CHROME_WINDOW, Roles.DOCUMENT, Roles.APPLICATION].indexOf(acc.role) < 0) { this.contentControl.autoMove(acc); } if (this.inTest) { this.sendMsgFunc("AccessFu:Focused"); } break; } case Events.DOCUMENT_LOAD_COMPLETE: { let position = this.contentControl.vc.position; // Check if position is in the subtree of the DOCUMENT_LOAD_COMPLETE // event's dialog accesible or accessible document let subtreeRoot = aEvent.accessible.role === Roles.DIALOG ? aEvent.accessible : aEvent.accessibleDocument; if (aEvent.accessible === aEvent.accessibleDocument || (position && Utils.isInSubtree(position, subtreeRoot))) { // Do not automove into the document if the virtual cursor is already // positioned inside it. break; } this._preDialogPosition.set(aEvent.accessible.DOMNode, position); this.contentControl.autoMove(aEvent.accessible, { delay: 500 }); break; } case Events.VALUE_CHANGE: case Events.TEXT_VALUE_CHANGE: { let position = this.contentControl.vc.position; let target = aEvent.accessible; if (position === target || Utils.getEmbeddedControl(position) === target) { this.present(Presentation.valueChanged(target)); } else { let {liveRegion, isPolite} = this._handleLiveRegion(aEvent, ['text', 'all']); if (liveRegion) { this.present(Presentation.valueChanged(target, isPolite)); } } } } }, _setEditingMode: function _setEditingMode(aEvent, aCaretOffset) { let acc = aEvent.accessible; let accText, characterCount; let caretOffset = aCaretOffset; try { accText = acc.QueryInterface(Ci.nsIAccessibleText); } catch (e) { // No text interface on this accessible. } if (accText) { characterCount = accText.characterCount; if (caretOffset === undefined) { caretOffset = accText.caretOffset; } } // Update editing state, both for presenter and other things let state = Utils.getState(acc); let editState = { editing: state.contains(States.EDITABLE) && state.contains(States.FOCUSED), multiline: state.contains(States.MULTI_LINE), atStart: caretOffset === 0, atEnd: caretOffset === characterCount }; // Not interesting if (!editState.editing && editState.editing === this.editState.editing) { return; } if (editState.editing !== this.editState.editing) { this.present(Presentation.editingModeChanged(editState.editing)); } if (editState.editing !== this.editState.editing || editState.multiline !== this.editState.multiline || editState.atEnd !== this.editState.atEnd || editState.atStart !== this.editState.atStart) { this.sendMsgFunc("AccessFu:Input", editState); } this.editState = editState; }, _handleShow: function _handleShow(aEvent) { let {liveRegion, isPolite} = this._handleLiveRegion(aEvent, ['additions', 'all']); // Only handle show if it is a relevant live region. if (!liveRegion) { return; } // Show for text is handled by the EVENT_TEXT_INSERTED handler. if (aEvent.accessible.role === Roles.TEXT_LEAF) { return; } this._dequeueLiveEvent(Events.HIDE, liveRegion); this.present(Presentation.liveRegion(liveRegion, isPolite, false)); }, _handleHide: function _handleHide(aEvent) { let {liveRegion, isPolite} = this._handleLiveRegion( aEvent, ['removals', 'all']); let acc = aEvent.accessible; if (liveRegion) { // Hide for text is handled by the EVENT_TEXT_REMOVED handler. if (acc.role === Roles.TEXT_LEAF) { return; } this._queueLiveEvent(Events.HIDE, liveRegion, isPolite); } else { let vc = Utils.getVirtualCursor(this.contentScope.content.document); if (vc.position && (Utils.getState(vc.position).contains(States.DEFUNCT) || Utils.isInSubtree(vc.position, acc))) { let position = this._preDialogPosition.get(aEvent.accessible.DOMNode) || aEvent.targetPrevSibling || aEvent.targetParent; if (!position) { try { position = acc.previousSibling; } catch (x) { // Accessible is unattached from the accessible tree. position = acc.parent; } } this.contentControl.autoMove(position, { moveToFocused: true, delay: 500 }); } } }, _handleText: function _handleText(aEvent, aLiveRegion, aIsPolite) { let event = aEvent.QueryInterface(Ci.nsIAccessibleTextChangeEvent); let isInserted = event.isInserted; let txtIface = aEvent.accessible.QueryInterface(Ci.nsIAccessibleText); let text = ''; try { text = txtIface.getText(0, Ci.nsIAccessibleText.TEXT_OFFSET_END_OF_TEXT); } catch (x) { // XXX we might have gotten an exception with of a // zero-length text. If we did, ignore it (bug #749810). if (txtIface.characterCount) { throw x; } } // If there are embedded objects in the text, ignore them. // Assuming changes to the descendants would already be handled by the // show/hide event. let modifiedText = event.modifiedText.replace(/\uFFFC/g, ''); if (modifiedText != event.modifiedText && !modifiedText.trim()) { return; } if (aLiveRegion) { if (aEvent.eventType === Events.TEXT_REMOVED) { this._queueLiveEvent(Events.TEXT_REMOVED, aLiveRegion, aIsPolite, modifiedText); } else { this._dequeueLiveEvent(Events.TEXT_REMOVED, aLiveRegion); this.present(Presentation.liveRegion(aLiveRegion, aIsPolite, false, modifiedText)); } } else { this.present(Presentation.textChanged(aEvent.accessible, isInserted, event.start, event.length, text, modifiedText)); } }, _handleLiveRegion: function _handleLiveRegion(aEvent, aRelevant) { if (aEvent.isFromUserInput) { return {}; } let parseLiveAttrs = function parseLiveAttrs(aAccessible) { let attrs = Utils.getAttributes(aAccessible); if (attrs['container-live']) { return { live: attrs['container-live'], relevant: attrs['container-relevant'] || 'additions text', busy: attrs['container-busy'], atomic: attrs['container-atomic'], memberOf: attrs['member-of'] }; } return null; }; // XXX live attributes are not set for hidden accessibles yet. Need to // climb up the tree to check for them. let getLiveAttributes = function getLiveAttributes(aEvent) { let liveAttrs = parseLiveAttrs(aEvent.accessible); if (liveAttrs) { return liveAttrs; } let parent = aEvent.targetParent; while (parent) { liveAttrs = parseLiveAttrs(parent); if (liveAttrs) { return liveAttrs; } parent = parent.parent } return {}; }; let {live, relevant, busy, atomic, memberOf} = getLiveAttributes(aEvent); // If container-live is not present or is set to |off| ignore the event. if (!live || live === 'off') { return {}; } // XXX: support busy and atomic. // Determine if the type of the mutation is relevant. Default is additions // and text. let isRelevant = Utils.matchAttributeValue(relevant, aRelevant); if (!isRelevant) { return {}; } return { liveRegion: aEvent.accessible, isPolite: live === 'polite' }; }, _dequeueLiveEvent: function _dequeueLiveEvent(aEventType, aLiveRegion) { let domNode = aLiveRegion.DOMNode; if (this._liveEventQueue && this._liveEventQueue.has(domNode)) { let queue = this._liveEventQueue.get(domNode); let nextEvent = queue[0]; if (nextEvent.eventType === aEventType) { Utils.win.clearTimeout(nextEvent.timeoutID); queue.shift(); if (queue.length === 0) { this._liveEventQueue.delete(domNode) } } } }, _queueLiveEvent: function _queueLiveEvent(aEventType, aLiveRegion, aIsPolite, aModifiedText) { if (!this._liveEventQueue) { this._liveEventQueue = new WeakMap(); } let eventHandler = { eventType: aEventType, timeoutID: Utils.win.setTimeout(this.present.bind(this), 20, // Wait for a possible EVENT_SHOW or EVENT_TEXT_INSERTED event. Presentation.liveRegion(aLiveRegion, aIsPolite, true, aModifiedText)) }; let domNode = aLiveRegion.DOMNode; if (this._liveEventQueue.has(domNode)) { this._liveEventQueue.get(domNode).push(eventHandler); } else { this._liveEventQueue.set(domNode, [eventHandler]); } }, present: function present(aPresentationData) { this.sendMsgFunc("AccessFu:Present", aPresentationData); }, onStateChange: function onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { let tabstate = ''; let loadingState = Ci.nsIWebProgressListener.STATE_TRANSFERRING | Ci.nsIWebProgressListener.STATE_IS_DOCUMENT; let loadedState = Ci.nsIWebProgressListener.STATE_STOP | Ci.nsIWebProgressListener.STATE_IS_NETWORK; if ((aStateFlags & loadingState) == loadingState) { tabstate = 'loading'; } else if ((aStateFlags & loadedState) == loadedState && !aWebProgress.isLoadingDocument) { tabstate = 'loaded'; } if (tabstate) { let docAcc = Utils.AccService.getAccessibleFor(aWebProgress.DOMWindow.document); this.present(Presentation.tabStateChanged(docAcc, tabstate)); } }, onProgressChange: function onProgressChange() {}, onLocationChange: function onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { let docAcc = Utils.AccService.getAccessibleFor(aWebProgress.DOMWindow.document); this.present(Presentation.tabStateChanged(docAcc, 'newdoc')); }, onStatusChange: function onStatusChange() {}, onSecurityChange: function onSecurityChange() {}, QueryInterface: XPCOMUtils.generateQI([Ci.nsIWebProgressListener, Ci.nsISupportsWeakReference, Ci.nsISupports, Ci.nsIObserver]) }; const AccessibilityEventObserver = { /** * A WeakMap containing [content, EventManager] pairs. */ eventManagers: new WeakMap(), /** * A total number of registered eventManagers. */ listenerCount: 0, /** * An indicator of an active 'accessible-event' observer. */ started: false, /** * Start an AccessibilityEventObserver. */ start: function start() { if (this.started || this.listenerCount === 0) { return; } Services.obs.addObserver(this, 'accessible-event', false); this.started = true; }, /** * Stop an AccessibilityEventObserver. */ stop: function stop() { if (!this.started) { return; } Services.obs.removeObserver(this, 'accessible-event'); // Clean up all registered event managers. this.eventManagers = new WeakMap(); this.listenerCount = 0; this.started = false; }, /** * Register an EventManager and start listening to the * 'accessible-event' messages. * * @param aEventManager EventManager * An EventManager object that was loaded into the specific content. */ addListener: function addListener(aEventManager) { let content = aEventManager.contentScope.content; if (!this.eventManagers.has(content)) { this.listenerCount++; } this.eventManagers.set(content, aEventManager); // Since at least one EventManager was registered, start listening. Logger.debug('AccessibilityEventObserver.addListener. Total:', this.listenerCount); this.start(); }, /** * Unregister an EventManager and, optionally, stop listening to the * 'accessible-event' messages. * * @param aEventManager EventManager * An EventManager object that was stopped in the specific content. */ removeListener: function removeListener(aEventManager) { let content = aEventManager.contentScope.content; if (!this.eventManagers.delete(content)) { return; } this.listenerCount--; Logger.debug('AccessibilityEventObserver.removeListener. Total:', this.listenerCount); if (this.listenerCount === 0) { // If there are no EventManagers registered at the moment, stop listening // to the 'accessible-event' messages. this.stop(); } }, /** * Lookup an EventManager for a specific content. If the EventManager is not * found, walk up the hierarchy of parent windows. * @param content Window * A content Window used to lookup the corresponding EventManager. */ getListener: function getListener(content) { let eventManager = this.eventManagers.get(content); if (eventManager) { return eventManager; } let parent = content.parent; if (parent === content) { // There is no parent or the parent is of a different type. return null; } return this.getListener(parent); }, /** * Handle the 'accessible-event' message. */ observe: function observe(aSubject, aTopic, aData) { if (aTopic !== 'accessible-event') { return; } let event = aSubject.QueryInterface(Ci.nsIAccessibleEvent); if (!event.accessibleDocument) { Logger.warning( 'AccessibilityEventObserver.observe: no accessible document:', Logger.eventToString(event), "accessible:", Logger.accessibleToString(event.accessible)); return; } let content = event.accessibleDocument.window; // Match the content window to its EventManager. let eventManager = this.getListener(content); if (!eventManager || !eventManager._started) { if (Utils.MozBuildApp === 'browser' && !(content instanceof Ci.nsIDOMChromeWindow)) { Logger.warning( 'AccessibilityEventObserver.observe: ignored event:', Logger.eventToString(event), "accessible:", Logger.accessibleToString(event.accessible), "document:", Logger.accessibleToString(event.accessibleDocument)); } return; } try { eventManager.handleAccEvent(event); } catch (x) { Logger.logException(x, 'Error handing accessible event'); } finally { return; } } };