From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- accessible/jsat/AccessFu.css | 59 ++ accessible/jsat/AccessFu.jsm | 1000 +++++++++++++++++++++ accessible/jsat/Constants.jsm | 59 ++ accessible/jsat/ContentControl.jsm | 528 +++++++++++ accessible/jsat/EventManager.jsm | 723 +++++++++++++++ accessible/jsat/Gestures.jsm | 956 ++++++++++++++++++++ accessible/jsat/OutputGenerator.jsm | 1003 +++++++++++++++++++++ accessible/jsat/PointerAdapter.jsm | 174 ++++ accessible/jsat/Presentation.jsm | 769 ++++++++++++++++ accessible/jsat/Traversal.jsm | 419 +++++++++ accessible/jsat/Utils.jsm | 1114 ++++++++++++++++++++++++ accessible/jsat/content-script.js | 151 ++++ accessible/jsat/jar.mn | 10 + accessible/jsat/moz.build | 20 + accessible/jsat/sounds/clicked.ogg | Bin 0 -> 6618 bytes accessible/jsat/sounds/virtual_cursor_key.ogg | Bin 0 -> 4224 bytes accessible/jsat/sounds/virtual_cursor_move.ogg | Bin 0 -> 5636 bytes 17 files changed, 6985 insertions(+) create mode 100644 accessible/jsat/AccessFu.css create mode 100644 accessible/jsat/AccessFu.jsm create mode 100644 accessible/jsat/Constants.jsm create mode 100644 accessible/jsat/ContentControl.jsm create mode 100644 accessible/jsat/EventManager.jsm create mode 100644 accessible/jsat/Gestures.jsm create mode 100644 accessible/jsat/OutputGenerator.jsm create mode 100644 accessible/jsat/PointerAdapter.jsm create mode 100644 accessible/jsat/Presentation.jsm create mode 100644 accessible/jsat/Traversal.jsm create mode 100644 accessible/jsat/Utils.jsm create mode 100644 accessible/jsat/content-script.js create mode 100644 accessible/jsat/jar.mn create mode 100644 accessible/jsat/moz.build create mode 100644 accessible/jsat/sounds/clicked.ogg create mode 100644 accessible/jsat/sounds/virtual_cursor_key.ogg create mode 100644 accessible/jsat/sounds/virtual_cursor_move.ogg (limited to 'accessible/jsat') diff --git a/accessible/jsat/AccessFu.css b/accessible/jsat/AccessFu.css new file mode 100644 index 000000000..d3930ff09 --- /dev/null +++ b/accessible/jsat/AccessFu.css @@ -0,0 +1,59 @@ +/* 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/. */ + +#virtual-cursor-box { + position: fixed; + border: 1px solid orange; + pointer-events: none; + display: none; + border-radius: 2px; + box-shadow: 1px 1px 1px #444; + display: none; + z-index: 10; +} + +#virtual-cursor-box.show { + display: block; +} + +#virtual-cursor-box > div { + border-radius: 1px; + box-shadow: inset 1px 1px 1px #444; + display: block; + box-sizing: border-box; + width: 100%; + height: 100%; + pointer-events: none; +} + +#announce-box { + position: fixed; + width: 7.5em; + height: 5em; + top: calc(100% - 50% - 2.5em); + left: calc(100% - 50% - 3.75em); + pointer-events: none; + display: table; + font-size: 28pt; + font-weight: 700; + color: orange; + background-color: black; + border-radius: 0.25em; +} + +#announce-box:not(.showing) { + opacity: 0.0; + -moz-transition: opacity 0.4s linear; +} + +#announce-box.showing { + opacity: 1.0; + -moz-transition: opacity 0.2s linear; +} + +#announce-box * { + text-align: center; + display: table-cell; + vertical-align: middle; +} diff --git a/accessible/jsat/AccessFu.jsm b/accessible/jsat/AccessFu.jsm new file mode 100644 index 000000000..c6b16b38f --- /dev/null +++ b/accessible/jsat/AccessFu.jsm @@ -0,0 +1,1000 @@ +/* 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/. */ + +/* global AccessFu, Components, Utils, PrefCache, Logger, Services, + PointerAdapter, dump, Presentation, Rect */ +/* exported AccessFu */ + +'use strict'; + +const {utils: Cu, interfaces: Ci} = Components; + +this.EXPORTED_SYMBOLS = ['AccessFu']; // jshint ignore:line + +Cu.import('resource://gre/modules/Services.jsm'); +Cu.import('resource://gre/modules/accessibility/Utils.jsm'); + +const ACCESSFU_DISABLE = 0; // jshint ignore:line +const ACCESSFU_ENABLE = 1; +const ACCESSFU_AUTO = 2; + +const SCREENREADER_SETTING = 'accessibility.screenreader'; +const QUICKNAV_MODES_PREF = 'accessibility.accessfu.quicknav_modes'; +const QUICKNAV_INDEX_PREF = 'accessibility.accessfu.quicknav_index'; + +this.AccessFu = { // jshint ignore:line + /** + * Initialize chrome-layer accessibility functionality. + * If accessibility is enabled on the platform, then a special accessibility + * mode is started. + */ + attach: function attach(aWindow) { + Utils.init(aWindow); + + try { + Services.androidBridge.handleGeckoMessage( + { type: 'Accessibility:Ready' }); + Services.obs.addObserver(this, 'Accessibility:Settings', false); + } catch (x) { + // Not on Android + if (aWindow.navigator.mozSettings) { + let lock = aWindow.navigator.mozSettings.createLock(); + let req = lock.get(SCREENREADER_SETTING); + req.addEventListener('success', () => { + this._systemPref = req.result[SCREENREADER_SETTING]; + this._enableOrDisable(); + }); + aWindow.navigator.mozSettings.addObserver( + SCREENREADER_SETTING, this.handleEvent); + } + } + + this._activatePref = new PrefCache( + 'accessibility.accessfu.activate', this._enableOrDisable.bind(this)); + + this._enableOrDisable(); + }, + + /** + * Shut down chrome-layer accessibility functionality from the outside. + */ + detach: function detach() { + // Avoid disabling twice. + if (this._enabled) { + this._disable(); + } + if (Utils.MozBuildApp === 'mobile/android') { + Services.obs.removeObserver(this, 'Accessibility:Settings'); + } else if (Utils.win.navigator.mozSettings) { + Utils.win.navigator.mozSettings.removeObserver( + SCREENREADER_SETTING, this.handleEvent); + } + delete this._activatePref; + Utils.uninit(); + }, + + /** + * A lazy getter for event handler that binds the scope to AccessFu object. + */ + get handleEvent() { + delete this.handleEvent; + this.handleEvent = this._handleEvent.bind(this); + return this.handleEvent; + }, + + /** + * Start AccessFu mode, this primarily means controlling the virtual cursor + * with arrow keys. + */ + _enable: function _enable() { + if (this._enabled) { + return; + } + this._enabled = true; + + Cu.import('resource://gre/modules/accessibility/Utils.jsm'); + Cu.import('resource://gre/modules/accessibility/PointerAdapter.jsm'); + Cu.import('resource://gre/modules/accessibility/Presentation.jsm'); + + for (let mm of Utils.AllMessageManagers) { + this._addMessageListeners(mm); + this._loadFrameScript(mm); + } + + // Add stylesheet + let stylesheetURL = 'chrome://global/content/accessibility/AccessFu.css'; + let stylesheet = Utils.win.document.createProcessingInstruction( + 'xml-stylesheet', 'href="' + stylesheetURL + '" type="text/css"'); + Utils.win.document.insertBefore(stylesheet, Utils.win.document.firstChild); + this.stylesheet = Cu.getWeakReference(stylesheet); + + + // Populate quicknav modes + this._quicknavModesPref = + new PrefCache(QUICKNAV_MODES_PREF, (aName, aValue, aFirstRun) => { + this.Input.quickNavMode.updateModes(aValue); + if (!aFirstRun) { + // If the modes change, reset the current mode index to 0. + Services.prefs.setIntPref(QUICKNAV_INDEX_PREF, 0); + } + }, true); + + this._quicknavCurrentModePref = + new PrefCache(QUICKNAV_INDEX_PREF, (aName, aValue) => { + this.Input.quickNavMode.updateCurrentMode(Number(aValue)); + }, true); + + // Check for output notification + this._notifyOutputPref = + new PrefCache('accessibility.accessfu.notify_output'); + + + this.Input.start(); + Output.start(); + PointerAdapter.start(); + + Services.obs.addObserver(this, 'remote-browser-shown', false); + Services.obs.addObserver(this, 'inprocess-browser-shown', false); + Services.obs.addObserver(this, 'Accessibility:NextObject', false); + Services.obs.addObserver(this, 'Accessibility:PreviousObject', false); + Services.obs.addObserver(this, 'Accessibility:Focus', false); + Services.obs.addObserver(this, 'Accessibility:ActivateObject', false); + Services.obs.addObserver(this, 'Accessibility:LongPress', false); + Services.obs.addObserver(this, 'Accessibility:ScrollForward', false); + Services.obs.addObserver(this, 'Accessibility:ScrollBackward', false); + Services.obs.addObserver(this, 'Accessibility:MoveByGranularity', false); + Utils.win.addEventListener('TabOpen', this); + Utils.win.addEventListener('TabClose', this); + Utils.win.addEventListener('TabSelect', this); + + if (this.readyCallback) { + this.readyCallback(); + delete this.readyCallback; + } + + Logger.info('AccessFu:Enabled'); + }, + + /** + * Disable AccessFu and return to default interaction mode. + */ + _disable: function _disable() { + if (!this._enabled) { + return; + } + + this._enabled = false; + + Utils.win.document.removeChild(this.stylesheet.get()); + + for (let mm of Utils.AllMessageManagers) { + mm.sendAsyncMessage('AccessFu:Stop'); + this._removeMessageListeners(mm); + } + + this.Input.stop(); + Output.stop(); + PointerAdapter.stop(); + + Utils.win.removeEventListener('TabOpen', this); + Utils.win.removeEventListener('TabClose', this); + Utils.win.removeEventListener('TabSelect', this); + + Services.obs.removeObserver(this, 'remote-browser-shown'); + Services.obs.removeObserver(this, 'inprocess-browser-shown'); + Services.obs.removeObserver(this, 'Accessibility:NextObject'); + Services.obs.removeObserver(this, 'Accessibility:PreviousObject'); + Services.obs.removeObserver(this, 'Accessibility:Focus'); + Services.obs.removeObserver(this, 'Accessibility:ActivateObject'); + Services.obs.removeObserver(this, 'Accessibility:LongPress'); + Services.obs.removeObserver(this, 'Accessibility:ScrollForward'); + Services.obs.removeObserver(this, 'Accessibility:ScrollBackward'); + Services.obs.removeObserver(this, 'Accessibility:MoveByGranularity'); + + delete this._quicknavModesPref; + delete this._notifyOutputPref; + + if (this.doneCallback) { + this.doneCallback(); + delete this.doneCallback; + } + + Logger.info('AccessFu:Disabled'); + }, + + _enableOrDisable: function _enableOrDisable() { + try { + if (!this._activatePref) { + return; + } + let activatePref = this._activatePref.value; + if (activatePref == ACCESSFU_ENABLE || + this._systemPref && activatePref == ACCESSFU_AUTO) { + this._enable(); + } else { + this._disable(); + } + } catch (x) { + dump('Error ' + x.message + ' ' + x.fileName + ':' + x.lineNumber); + } + }, + + receiveMessage: function receiveMessage(aMessage) { + Logger.debug(() => { + return ['Recieved', aMessage.name, JSON.stringify(aMessage.json)]; + }); + + switch (aMessage.name) { + case 'AccessFu:Ready': + let mm = Utils.getMessageManager(aMessage.target); + if (this._enabled) { + mm.sendAsyncMessage('AccessFu:Start', + {method: 'start', buildApp: Utils.MozBuildApp}); + } + break; + case 'AccessFu:Present': + this._output(aMessage.json, aMessage.target); + break; + case 'AccessFu:Input': + this.Input.setEditState(aMessage.json); + break; + case 'AccessFu:DoScroll': + this.Input.doScroll(aMessage.json); + break; + } + }, + + _output: function _output(aPresentationData, aBrowser) { + if (!Utils.isAliveAndVisible( + Utils.AccService.getAccessibleFor(aBrowser))) { + return; + } + for (let presenter of aPresentationData) { + if (!presenter) { + continue; + } + + try { + Output[presenter.type](presenter.details, aBrowser); + } catch (x) { + Logger.logException(x); + } + } + + if (this._notifyOutputPref.value) { + Services.obs.notifyObservers(null, 'accessibility-output', + JSON.stringify(aPresentationData)); + } + }, + + _loadFrameScript: function _loadFrameScript(aMessageManager) { + if (this._processedMessageManagers.indexOf(aMessageManager) < 0) { + aMessageManager.loadFrameScript( + 'chrome://global/content/accessibility/content-script.js', true); + this._processedMessageManagers.push(aMessageManager); + } else if (this._enabled) { + // If the content-script is already loaded and AccessFu is enabled, + // send an AccessFu:Start message. + aMessageManager.sendAsyncMessage('AccessFu:Start', + {method: 'start', buildApp: Utils.MozBuildApp}); + } + }, + + _addMessageListeners: function _addMessageListeners(aMessageManager) { + aMessageManager.addMessageListener('AccessFu:Present', this); + aMessageManager.addMessageListener('AccessFu:Input', this); + aMessageManager.addMessageListener('AccessFu:Ready', this); + aMessageManager.addMessageListener('AccessFu:DoScroll', this); + }, + + _removeMessageListeners: function _removeMessageListeners(aMessageManager) { + aMessageManager.removeMessageListener('AccessFu:Present', this); + aMessageManager.removeMessageListener('AccessFu:Input', this); + aMessageManager.removeMessageListener('AccessFu:Ready', this); + aMessageManager.removeMessageListener('AccessFu:DoScroll', this); + }, + + _handleMessageManager: function _handleMessageManager(aMessageManager) { + if (this._enabled) { + this._addMessageListeners(aMessageManager); + } + this._loadFrameScript(aMessageManager); + }, + + observe: function observe(aSubject, aTopic, aData) { + switch (aTopic) { + case 'Accessibility:Settings': + this._systemPref = JSON.parse(aData).enabled; + this._enableOrDisable(); + break; + case 'Accessibility:NextObject': + case 'Accessibility:PreviousObject': + { + let rule = aData ? + aData.substr(0, 1).toUpperCase() + aData.substr(1).toLowerCase() : + 'Simple'; + let method = aTopic.replace(/Accessibility:(\w+)Object/, 'move$1'); + this.Input.moveCursor(method, rule, 'gesture'); + break; + } + case 'Accessibility:ActivateObject': + this.Input.activateCurrent(JSON.parse(aData)); + break; + case 'Accessibility:LongPress': + this.Input.sendContextMenuMessage(); + break; + case 'Accessibility:ScrollForward': + this.Input.androidScroll('forward'); + break; + case 'Accessibility:ScrollBackward': + this.Input.androidScroll('backward'); + break; + case 'Accessibility:Focus': + this._focused = JSON.parse(aData); + if (this._focused) { + this.autoMove({ forcePresent: true, noOpIfOnScreen: true }); + } + break; + case 'Accessibility:MoveByGranularity': + this.Input.moveByGranularity(JSON.parse(aData)); + break; + case 'remote-browser-shown': + case 'inprocess-browser-shown': + { + // Ignore notifications that aren't from a BrowserOrApp + let frameLoader = aSubject.QueryInterface(Ci.nsIFrameLoader); + if (!frameLoader.ownerIsMozBrowserOrAppFrame) { + return; + } + this._handleMessageManager(frameLoader.messageManager); + break; + } + } + }, + + _handleEvent: function _handleEvent(aEvent) { + switch (aEvent.type) { + case 'TabOpen': + { + let mm = Utils.getMessageManager(aEvent.target); + this._handleMessageManager(mm); + break; + } + case 'TabClose': + { + let mm = Utils.getMessageManager(aEvent.target); + let mmIndex = this._processedMessageManagers.indexOf(mm); + if (mmIndex > -1) { + this._removeMessageListeners(mm); + this._processedMessageManagers.splice(mmIndex, 1); + } + break; + } + case 'TabSelect': + { + if (this._focused) { + // We delay this for half a second so the awesomebar could close, + // and we could use the current coordinates for the content item. + // XXX TODO figure out how to avoid magic wait here. + this.autoMove({ + delay: 500, + forcePresent: true, + noOpIfOnScreen: true, + moveMethod: 'moveFirst' }); + } + break; + } + default: + { + // A settings change, it does not have an event type + if (aEvent.settingName == SCREENREADER_SETTING) { + this._systemPref = aEvent.settingValue; + this._enableOrDisable(); + } + break; + } + } + }, + + autoMove: function autoMove(aOptions) { + let mm = Utils.getMessageManager(Utils.CurrentBrowser); + mm.sendAsyncMessage('AccessFu:AutoMove', aOptions); + }, + + announce: function announce(aAnnouncement) { + this._output(Presentation.announce(aAnnouncement), Utils.CurrentBrowser); + }, + + // So we don't enable/disable twice + _enabled: false, + + // Layerview is focused + _focused: false, + + // Keep track of message managers tha already have a 'content-script.js' + // injected. + _processedMessageManagers: [], + + /** + * Adjusts the given bounds relative to the given browser. + * @param {Rect} aJsonBounds the bounds to adjust + * @param {browser} aBrowser the browser we want the bounds relative to + * @param {bool} aToCSSPixels whether to convert to CSS pixels (as opposed to + * device pixels) + */ + adjustContentBounds: + function(aJsonBounds, aBrowser, aToCSSPixels) { + let bounds = new Rect(aJsonBounds.left, aJsonBounds.top, + aJsonBounds.right - aJsonBounds.left, + aJsonBounds.bottom - aJsonBounds.top); + let win = Utils.win; + let dpr = win.devicePixelRatio; + let offset = { left: -win.mozInnerScreenX, top: -win.mozInnerScreenY }; + + // Add the offset; the offset is in CSS pixels, so multiply the + // devicePixelRatio back in before adding to preserve unit consistency. + bounds = bounds.translate(offset.left * dpr, offset.top * dpr); + + // If we want to get to CSS pixels from device pixels, this needs to be + // further divided by the devicePixelRatio due to widget scaling. + if (aToCSSPixels) { + bounds = bounds.scale(1 / dpr, 1 / dpr); + } + + return bounds.expandToIntegers(); + } +}; + +var Output = { + brailleState: { + startOffset: 0, + endOffset: 0, + text: '', + selectionStart: 0, + selectionEnd: 0, + + init: function init(aOutput) { + if (aOutput && 'output' in aOutput) { + this.startOffset = aOutput.startOffset; + this.endOffset = aOutput.endOffset; + // We need to append a space at the end so that the routing key + // corresponding to the end of the output (i.e. the space) can be hit to + // move the caret there. + this.text = aOutput.output + ' '; + this.selectionStart = typeof aOutput.selectionStart === 'number' ? + aOutput.selectionStart : this.selectionStart; + this.selectionEnd = typeof aOutput.selectionEnd === 'number' ? + aOutput.selectionEnd : this.selectionEnd; + + return { text: this.text, + selectionStart: this.selectionStart, + selectionEnd: this.selectionEnd }; + } + + return null; + }, + + adjustText: function adjustText(aText) { + let newBraille = []; + let braille = {}; + + let prefix = this.text.substring(0, this.startOffset).trim(); + if (prefix) { + prefix += ' '; + newBraille.push(prefix); + } + + newBraille.push(aText); + + let suffix = this.text.substring(this.endOffset).trim(); + if (suffix) { + suffix = ' ' + suffix; + newBraille.push(suffix); + } + + this.startOffset = braille.startOffset = prefix.length; + this.text = braille.text = newBraille.join('') + ' '; + this.endOffset = braille.endOffset = braille.text.length - suffix.length; + braille.selectionStart = this.selectionStart; + braille.selectionEnd = this.selectionEnd; + + return braille; + }, + + adjustSelection: function adjustSelection(aSelection) { + let braille = {}; + + braille.startOffset = this.startOffset; + braille.endOffset = this.endOffset; + braille.text = this.text; + this.selectionStart = braille.selectionStart = + aSelection.selectionStart + this.startOffset; + this.selectionEnd = braille.selectionEnd = + aSelection.selectionEnd + this.startOffset; + + return braille; + } + }, + + start: function start() { + Cu.import('resource://gre/modules/Geometry.jsm'); + }, + + stop: function stop() { + if (this.highlightBox) { + let highlightBox = this.highlightBox.get(); + if (highlightBox) { + highlightBox.remove(); + } + delete this.highlightBox; + } + }, + + B2G: function B2G(aDetails) { + Utils.dispatchChromeEvent('accessibility-output', aDetails); + }, + + Visual: function Visual(aDetail, aBrowser) { + switch (aDetail.eventType) { + case 'viewport-change': + case 'vc-change': + { + let highlightBox = null; + if (!this.highlightBox) { + let doc = Utils.win.document; + // Add highlight box + highlightBox = Utils.win.document. + createElementNS('http://www.w3.org/1999/xhtml', 'div'); + let parent = doc.body || doc.documentElement; + parent.appendChild(highlightBox); + highlightBox.id = 'virtual-cursor-box'; + + // Add highlight inset for inner shadow + highlightBox.appendChild( + doc.createElementNS('http://www.w3.org/1999/xhtml', 'div')); + + this.highlightBox = Cu.getWeakReference(highlightBox); + } else { + highlightBox = this.highlightBox.get(); + } + + let padding = aDetail.padding; + let r = AccessFu.adjustContentBounds(aDetail.bounds, aBrowser, true); + + // First hide it to avoid flickering when changing the style. + highlightBox.classList.remove('show'); + highlightBox.style.top = (r.top - padding) + 'px'; + highlightBox.style.left = (r.left - padding) + 'px'; + highlightBox.style.width = (r.width + padding*2) + 'px'; + highlightBox.style.height = (r.height + padding*2) + 'px'; + highlightBox.classList.add('show'); + + break; + } + case 'tabstate-change': + { + let highlightBox = this.highlightBox ? this.highlightBox.get() : null; + if (highlightBox) { + highlightBox.classList.remove('show'); + } + break; + } + } + }, + + get androidBridge() { + delete this.androidBridge; + if (Utils.MozBuildApp === 'mobile/android') { + this.androidBridge = Services.androidBridge; + } else { + this.androidBridge = null; + } + return this.androidBridge; + }, + + Android: function Android(aDetails, aBrowser) { + const ANDROID_VIEW_TEXT_CHANGED = 0x10; + const ANDROID_VIEW_TEXT_SELECTION_CHANGED = 0x2000; + + if (!this.androidBridge) { + return; + } + + for (let androidEvent of aDetails) { + androidEvent.type = 'Accessibility:Event'; + if (androidEvent.bounds) { + androidEvent.bounds = AccessFu.adjustContentBounds( + androidEvent.bounds, aBrowser); + } + + switch(androidEvent.eventType) { + case ANDROID_VIEW_TEXT_CHANGED: + androidEvent.brailleOutput = this.brailleState.adjustText( + androidEvent.text); + break; + case ANDROID_VIEW_TEXT_SELECTION_CHANGED: + androidEvent.brailleOutput = this.brailleState.adjustSelection( + androidEvent.brailleOutput); + break; + default: + androidEvent.brailleOutput = this.brailleState.init( + androidEvent.brailleOutput); + break; + } + this.androidBridge.handleGeckoMessage(androidEvent); + } + }, + + Braille: function Braille(aDetails) { + Logger.debug('Braille output: ' + aDetails.output); + } +}; + +var Input = { + editState: {}, + + start: function start() { + // XXX: This is too disruptive on desktop for now. + // Might need to add special modifiers. + if (Utils.MozBuildApp != 'browser') { + Utils.win.document.addEventListener('keypress', this, true); + } + Utils.win.addEventListener('mozAccessFuGesture', this, true); + }, + + stop: function stop() { + if (Utils.MozBuildApp != 'browser') { + Utils.win.document.removeEventListener('keypress', this, true); + } + Utils.win.removeEventListener('mozAccessFuGesture', this, true); + }, + + handleEvent: function Input_handleEvent(aEvent) { + try { + switch (aEvent.type) { + case 'keypress': + this._handleKeypress(aEvent); + break; + case 'mozAccessFuGesture': + this._handleGesture(aEvent.detail); + break; + } + } catch (x) { + Logger.logException(x); + } + }, + + _handleGesture: function _handleGesture(aGesture) { + let gestureName = aGesture.type + aGesture.touches.length; + Logger.debug('Gesture', aGesture.type, + '(fingers: ' + aGesture.touches.length + ')'); + + switch (gestureName) { + case 'dwell1': + case 'explore1': + this.moveToPoint('Simple', aGesture.touches[0].x, + aGesture.touches[0].y); + break; + case 'doubletap1': + this.activateCurrent(); + break; + case 'doubletaphold1': + Utils.dispatchChromeEvent('accessibility-control', 'quicknav-menu'); + break; + case 'swiperight1': + this.moveCursor('moveNext', 'Simple', 'gestures'); + break; + case 'swipeleft1': + this.moveCursor('movePrevious', 'Simple', 'gesture'); + break; + case 'swipeup1': + this.moveCursor( + 'movePrevious', this.quickNavMode.current, 'gesture', true); + break; + case 'swipedown1': + this.moveCursor('moveNext', this.quickNavMode.current, 'gesture', true); + break; + case 'exploreend1': + case 'dwellend1': + this.activateCurrent(null, true); + break; + case 'swiperight2': + if (aGesture.edge) { + Utils.dispatchChromeEvent('accessibility-control', + 'edge-swipe-right'); + break; + } + this.sendScrollMessage(-1, true); + break; + case 'swipedown2': + if (aGesture.edge) { + Utils.dispatchChromeEvent('accessibility-control', 'edge-swipe-down'); + break; + } + this.sendScrollMessage(-1); + break; + case 'swipeleft2': + if (aGesture.edge) { + Utils.dispatchChromeEvent('accessibility-control', 'edge-swipe-left'); + break; + } + this.sendScrollMessage(1, true); + break; + case 'swipeup2': + if (aGesture.edge) { + Utils.dispatchChromeEvent('accessibility-control', 'edge-swipe-up'); + break; + } + this.sendScrollMessage(1); + break; + case 'explore2': + Utils.CurrentBrowser.contentWindow.scrollBy( + -aGesture.deltaX, -aGesture.deltaY); + break; + case 'swiperight3': + this.moveCursor('moveNext', this.quickNavMode.current, 'gesture'); + break; + case 'swipeleft3': + this.moveCursor('movePrevious', this.quickNavMode.current, 'gesture'); + break; + case 'swipedown3': + this.quickNavMode.next(); + AccessFu.announce('quicknav_' + this.quickNavMode.current); + break; + case 'swipeup3': + this.quickNavMode.previous(); + AccessFu.announce('quicknav_' + this.quickNavMode.current); + break; + case 'tripletap3': + Utils.dispatchChromeEvent('accessibility-control', 'toggle-shade'); + break; + case 'tap2': + Utils.dispatchChromeEvent('accessibility-control', 'toggle-pause'); + break; + } + }, + + _handleKeypress: function _handleKeypress(aEvent) { + let target = aEvent.target; + + // Ignore keys with modifiers so the content could take advantage of them. + if (aEvent.ctrlKey || aEvent.altKey || aEvent.metaKey) { + return; + } + + switch (aEvent.keyCode) { + case 0: + // an alphanumeric key was pressed, handle it separately. + // If it was pressed with either alt or ctrl, just pass through. + // If it was pressed with meta, pass the key on without the meta. + if (this.editState.editing) { + return; + } + + let key = String.fromCharCode(aEvent.charCode); + try { + let [methodName, rule] = this.keyMap[key]; + this.moveCursor(methodName, rule, 'keyboard'); + } catch (x) { + return; + } + break; + case aEvent.DOM_VK_RIGHT: + if (this.editState.editing) { + if (!this.editState.atEnd) { + // Don't move forward if caret is not at end of entry. + // XXX: Fix for rtl + return; + } else { + target.blur(); + } + } + this.moveCursor(aEvent.shiftKey ? + 'moveLast' : 'moveNext', 'Simple', 'keyboard'); + break; + case aEvent.DOM_VK_LEFT: + if (this.editState.editing) { + if (!this.editState.atStart) { + // Don't move backward if caret is not at start of entry. + // XXX: Fix for rtl + return; + } else { + target.blur(); + } + } + this.moveCursor(aEvent.shiftKey ? + 'moveFirst' : 'movePrevious', 'Simple', 'keyboard'); + break; + case aEvent.DOM_VK_UP: + if (this.editState.multiline) { + if (!this.editState.atStart) { + // Don't blur content if caret is not at start of text area. + return; + } else { + target.blur(); + } + } + + if (Utils.MozBuildApp == 'mobile/android') { + // Return focus to native Android browser chrome. + Services.androidBridge.handleGeckoMessage( + { type: 'ToggleChrome:Focus' }); + } + break; + case aEvent.DOM_VK_RETURN: + if (this.editState.editing) { + return; + } + this.activateCurrent(); + break; + default: + return; + } + + aEvent.preventDefault(); + aEvent.stopPropagation(); + }, + + moveToPoint: function moveToPoint(aRule, aX, aY) { + // XXX: Bug 1013408 - There is no alignment between the chrome window's + // viewport size and the content viewport size in Android. This makes + // sending mouse events beyond its bounds impossible. + if (Utils.MozBuildApp === 'mobile/android') { + let mm = Utils.getMessageManager(Utils.CurrentBrowser); + mm.sendAsyncMessage('AccessFu:MoveToPoint', + {rule: aRule, x: aX, y: aY, origin: 'top'}); + } else { + let win = Utils.win; + Utils.winUtils.sendMouseEvent('mousemove', + aX - win.mozInnerScreenX, aY - win.mozInnerScreenY, 0, 0, 0); + } + }, + + moveCursor: function moveCursor(aAction, aRule, aInputType, aAdjustRange) { + let mm = Utils.getMessageManager(Utils.CurrentBrowser); + mm.sendAsyncMessage('AccessFu:MoveCursor', + { action: aAction, rule: aRule, + origin: 'top', inputType: aInputType, + adjustRange: aAdjustRange }); + }, + + androidScroll: function androidScroll(aDirection) { + let mm = Utils.getMessageManager(Utils.CurrentBrowser); + mm.sendAsyncMessage('AccessFu:AndroidScroll', + { direction: aDirection, origin: 'top' }); + }, + + moveByGranularity: function moveByGranularity(aDetails) { + const GRANULARITY_PARAGRAPH = 8; + const GRANULARITY_LINE = 4; + + if (!this.editState.editing) { + if (aDetails.granularity & (GRANULARITY_PARAGRAPH | GRANULARITY_LINE)) { + this.moveCursor('move' + aDetails.direction, 'Simple', 'gesture'); + return; + } + } else { + aDetails.atStart = this.editState.atStart; + aDetails.atEnd = this.editState.atEnd; + } + + let mm = Utils.getMessageManager(Utils.CurrentBrowser); + let type = this.editState.editing ? 'AccessFu:MoveCaret' : + 'AccessFu:MoveByGranularity'; + mm.sendAsyncMessage(type, aDetails); + }, + + activateCurrent: function activateCurrent(aData, aActivateIfKey = false) { + let mm = Utils.getMessageManager(Utils.CurrentBrowser); + let offset = aData && typeof aData.keyIndex === 'number' ? + aData.keyIndex - Output.brailleState.startOffset : -1; + + mm.sendAsyncMessage('AccessFu:Activate', + {offset: offset, activateIfKey: aActivateIfKey}); + }, + + sendContextMenuMessage: function sendContextMenuMessage() { + let mm = Utils.getMessageManager(Utils.CurrentBrowser); + mm.sendAsyncMessage('AccessFu:ContextMenu', {}); + }, + + setEditState: function setEditState(aEditState) { + Logger.debug(() => { return ['setEditState', JSON.stringify(aEditState)] }); + this.editState = aEditState; + }, + + // XXX: This is here for backwards compatability with screen reader simulator + // it should be removed when the extension is updated on amo. + scroll: function scroll(aPage, aHorizontal) { + this.sendScrollMessage(aPage, aHorizontal); + }, + + sendScrollMessage: function sendScrollMessage(aPage, aHorizontal) { + let mm = Utils.getMessageManager(Utils.CurrentBrowser); + mm.sendAsyncMessage('AccessFu:Scroll', + {page: aPage, horizontal: aHorizontal, origin: 'top'}); + }, + + doScroll: function doScroll(aDetails) { + let horizontal = aDetails.horizontal; + let page = aDetails.page; + let p = AccessFu.adjustContentBounds( + aDetails.bounds, Utils.CurrentBrowser, true).center(); + Utils.winUtils.sendWheelEvent(p.x, p.y, + horizontal ? page : 0, horizontal ? 0 : page, 0, + Utils.win.WheelEvent.DOM_DELTA_PAGE, 0, 0, 0, 0); + }, + + get keyMap() { + delete this.keyMap; + this.keyMap = { + a: ['moveNext', 'Anchor'], + A: ['movePrevious', 'Anchor'], + b: ['moveNext', 'Button'], + B: ['movePrevious', 'Button'], + c: ['moveNext', 'Combobox'], + C: ['movePrevious', 'Combobox'], + d: ['moveNext', 'Landmark'], + D: ['movePrevious', 'Landmark'], + e: ['moveNext', 'Entry'], + E: ['movePrevious', 'Entry'], + f: ['moveNext', 'FormElement'], + F: ['movePrevious', 'FormElement'], + g: ['moveNext', 'Graphic'], + G: ['movePrevious', 'Graphic'], + h: ['moveNext', 'Heading'], + H: ['movePrevious', 'Heading'], + i: ['moveNext', 'ListItem'], + I: ['movePrevious', 'ListItem'], + k: ['moveNext', 'Link'], + K: ['movePrevious', 'Link'], + l: ['moveNext', 'List'], + L: ['movePrevious', 'List'], + p: ['moveNext', 'PageTab'], + P: ['movePrevious', 'PageTab'], + r: ['moveNext', 'RadioButton'], + R: ['movePrevious', 'RadioButton'], + s: ['moveNext', 'Separator'], + S: ['movePrevious', 'Separator'], + t: ['moveNext', 'Table'], + T: ['movePrevious', 'Table'], + x: ['moveNext', 'Checkbox'], + X: ['movePrevious', 'Checkbox'] + }; + + return this.keyMap; + }, + + quickNavMode: { + get current() { + return this.modes[this._currentIndex]; + }, + + previous: function quickNavMode_previous() { + Services.prefs.setIntPref(QUICKNAV_INDEX_PREF, + this._currentIndex > 0 ? + this._currentIndex - 1 : this.modes.length - 1); + }, + + next: function quickNavMode_next() { + Services.prefs.setIntPref(QUICKNAV_INDEX_PREF, + this._currentIndex + 1 >= this.modes.length ? + 0 : this._currentIndex + 1); + }, + + updateModes: function updateModes(aModes) { + if (aModes) { + this.modes = aModes.split(','); + } else { + this.modes = []; + } + }, + + updateCurrentMode: function updateCurrentMode(aModeIndex) { + Logger.debug('Quicknav mode:', this.modes[aModeIndex]); + this._currentIndex = aModeIndex; + } + } +}; +AccessFu.Input = Input; diff --git a/accessible/jsat/Constants.jsm b/accessible/jsat/Constants.jsm new file mode 100644 index 000000000..152604431 --- /dev/null +++ b/accessible/jsat/Constants.jsm @@ -0,0 +1,59 @@ +const Ci = Components.interfaces; +const Cu = Components.utils; + +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); + +this.EXPORTED_SYMBOLS = ['Roles', 'Events', 'Relations', + 'Filters', 'States', 'Prefilters']; + +function ConstantsMap (aObject, aPrefix, aMap = {}, aModifier = null) { + let offset = aPrefix.length; + for (var name in aObject) { + if (name.indexOf(aPrefix) === 0) { + aMap[name.slice(offset)] = aModifier ? + aModifier(aObject[name]) : aObject[name]; + } + } + + return aMap; +} + +XPCOMUtils.defineLazyGetter( + this, 'Roles', + function() { + return ConstantsMap(Ci.nsIAccessibleRole, 'ROLE_'); + }); + +XPCOMUtils.defineLazyGetter( + this, 'Events', + function() { + return ConstantsMap(Ci.nsIAccessibleEvent, 'EVENT_'); + }); + +XPCOMUtils.defineLazyGetter( + this, 'Relations', + function() { + return ConstantsMap(Ci.nsIAccessibleRelation, 'RELATION_'); + }); + +XPCOMUtils.defineLazyGetter( + this, 'Prefilters', + function() { + return ConstantsMap(Ci.nsIAccessibleTraversalRule, 'PREFILTER_'); + }); + +XPCOMUtils.defineLazyGetter( + this, 'Filters', + function() { + return ConstantsMap(Ci.nsIAccessibleTraversalRule, 'FILTER_'); + }); + +XPCOMUtils.defineLazyGetter( + this, 'States', + function() { + let statesMap = ConstantsMap(Ci.nsIAccessibleStates, 'STATE_', {}, + (val) => { return { base: val, extended: 0 }; }); + ConstantsMap(Ci.nsIAccessibleStates, 'EXT_STATE_', statesMap, + (val) => { return { base: 0, extended: val }; }); + return statesMap; + }); diff --git a/accessible/jsat/ContentControl.jsm b/accessible/jsat/ContentControl.jsm new file mode 100644 index 000000000..f5fd471ba --- /dev/null +++ b/accessible/jsat/ContentControl.jsm @@ -0,0 +1,528 @@ +/* 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/. */ + +var Ci = Components.interfaces; +var Cu = Components.utils; + +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, 'Roles', + 'resource://gre/modules/accessibility/Constants.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'TraversalRules', + 'resource://gre/modules/accessibility/Traversal.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'TraversalHelper', + 'resource://gre/modules/accessibility/Traversal.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Presentation', + 'resource://gre/modules/accessibility/Presentation.jsm'); + +this.EXPORTED_SYMBOLS = ['ContentControl']; + +const MOVEMENT_GRANULARITY_CHARACTER = 1; +const MOVEMENT_GRANULARITY_WORD = 2; +const MOVEMENT_GRANULARITY_PARAGRAPH = 8; + +this.ContentControl = function ContentControl(aContentScope) { + this._contentScope = Cu.getWeakReference(aContentScope); + this._childMessageSenders = new WeakMap(); +}; + +this.ContentControl.prototype = { + messagesOfInterest: ['AccessFu:MoveCursor', + 'AccessFu:ClearCursor', + 'AccessFu:MoveToPoint', + 'AccessFu:AutoMove', + 'AccessFu:Activate', + 'AccessFu:MoveCaret', + 'AccessFu:MoveByGranularity', + 'AccessFu:AndroidScroll'], + + start: function cc_start() { + let cs = this._contentScope.get(); + for (let message of this.messagesOfInterest) { + cs.addMessageListener(message, this); + } + cs.addEventListener('mousemove', this); + }, + + stop: function cc_stop() { + let cs = this._contentScope.get(); + for (let message of this.messagesOfInterest) { + cs.removeMessageListener(message, this); + } + cs.removeEventListener('mousemove', this); + }, + + get document() { + return this._contentScope.get().content.document; + }, + + get window() { + return this._contentScope.get().content; + }, + + get vc() { + return Utils.getVirtualCursor(this.document); + }, + + receiveMessage: function cc_receiveMessage(aMessage) { + Logger.debug(() => { + return ['ContentControl.receiveMessage', + aMessage.name, + JSON.stringify(aMessage.json)]; + }); + + // If we get an explicit message, we should immediately cancel any autoMove + this.cancelAutoMove(); + + try { + let func = this['handle' + aMessage.name.slice(9)]; // 'AccessFu:'.length + if (func) { + func.bind(this)(aMessage); + } else { + Logger.warning('ContentControl: Unhandled message:', aMessage.name); + } + } catch (x) { + Logger.logException( + x, 'Error handling message: ' + JSON.stringify(aMessage.json)); + } + }, + + handleAndroidScroll: function cc_handleAndroidScroll(aMessage) { + let vc = this.vc; + let position = vc.position; + + if (aMessage.json.origin != 'child' && this.sendToChild(vc, aMessage)) { + // Forwarded succesfully to child cursor. + return; + } + + // Counter-intuitive, but scrolling backward (ie. up), actually should + // increase range values. + if (this.adjustRange(position, aMessage.json.direction === 'backward')) { + return; + } + + this._contentScope.get().sendAsyncMessage('AccessFu:DoScroll', + { bounds: Utils.getBounds(position, true), + page: aMessage.json.direction === 'forward' ? 1 : -1, + horizontal: false }); + }, + + handleMoveCursor: function cc_handleMoveCursor(aMessage) { + let origin = aMessage.json.origin; + let action = aMessage.json.action; + let adjustRange = aMessage.json.adjustRange; + let vc = this.vc; + + if (origin != 'child' && this.sendToChild(vc, aMessage)) { + // Forwarded succesfully to child cursor. + return; + } + + if (adjustRange && this.adjustRange(vc.position, action === 'moveNext')) { + return; + } + + let moved = TraversalHelper.move(vc, action, aMessage.json.rule); + + if (moved) { + if (origin === 'child') { + // We just stepped out of a child, clear child cursor. + Utils.getMessageManager(aMessage.target).sendAsyncMessage( + 'AccessFu:ClearCursor', {}); + } else { + // We potentially landed on a new child cursor. If so, we want to + // either be on the first or last item in the child doc. + let childAction = action; + if (action === 'moveNext') { + childAction = 'moveFirst'; + } else if (action === 'movePrevious') { + childAction = 'moveLast'; + } + + // Attempt to forward move to a potential child cursor in our + // new position. + this.sendToChild(vc, aMessage, { action: childAction }, true); + } + } else if (!this._childMessageSenders.has(aMessage.target) && + origin !== 'top') { + // We failed to move, and the message is not from a parent, so forward + // to it. + this.sendToParent(aMessage); + } else { + this._contentScope.get().sendAsyncMessage('AccessFu:Present', + Presentation.noMove(action)); + } + }, + + handleEvent: function cc_handleEvent(aEvent) { + if (aEvent.type === 'mousemove') { + this.handleMoveToPoint( + { json: { x: aEvent.screenX, y: aEvent.screenY, rule: 'Simple' } }); + } + if (!Utils.getMessageManager(aEvent.target)) { + aEvent.preventDefault(); + } else { + aEvent.target.focus(); + } + }, + + handleMoveToPoint: function cc_handleMoveToPoint(aMessage) { + let [x, y] = [aMessage.json.x, aMessage.json.y]; + let rule = TraversalRules[aMessage.json.rule]; + + let dpr = this.window.devicePixelRatio; + this.vc.moveToPoint(rule, x * dpr, y * dpr, true); + }, + + handleClearCursor: function cc_handleClearCursor(aMessage) { + let forwarded = this.sendToChild(this.vc, aMessage); + this.vc.position = null; + if (!forwarded) { + this._contentScope.get().sendAsyncMessage('AccessFu:CursorCleared'); + } + this.document.activeElement.blur(); + }, + + handleAutoMove: function cc_handleAutoMove(aMessage) { + this.autoMove(null, aMessage.json); + }, + + handleActivate: function cc_handleActivate(aMessage) { + let activateAccessible = (aAccessible) => { + Logger.debug(() => { + return ['activateAccessible', Logger.accessibleToString(aAccessible)]; + }); + try { + if (aMessage.json.activateIfKey && + !Utils.isActivatableOnFingerUp(aAccessible)) { + // Only activate keys, don't do anything on other objects. + return; + } + } catch (e) { + // accessible is invalid. Silently fail. + return; + } + + if (aAccessible.actionCount > 0) { + aAccessible.doAction(0); + } else { + let control = Utils.getEmbeddedControl(aAccessible); + if (control && control.actionCount > 0) { + control.doAction(0); + } + + // XXX Some mobile widget sets do not expose actions properly + // (via ARIA roles, etc.), so we need to generate a click. + // Could possibly be made simpler in the future. Maybe core + // engine could expose nsCoreUtiles::DispatchMouseEvent()? + let docAcc = Utils.AccService.getAccessibleFor(this.document); + let docX = {}, docY = {}, docW = {}, docH = {}; + docAcc.getBounds(docX, docY, docW, docH); + + let objX = {}, objY = {}, objW = {}, objH = {}; + aAccessible.getBounds(objX, objY, objW, objH); + + let x = Math.round((objX.value - docX.value) + objW.value / 2); + let y = Math.round((objY.value - docY.value) + objH.value / 2); + + let node = aAccessible.DOMNode || aAccessible.parent.DOMNode; + + for (let eventType of ['mousedown', 'mouseup']) { + let evt = this.document.createEvent('MouseEvents'); + evt.initMouseEvent(eventType, true, true, this.window, + x, y, 0, 0, 0, false, false, false, false, 0, null); + node.dispatchEvent(evt); + } + } + + if (!Utils.isActivatableOnFingerUp(aAccessible)) { + // Keys will typically have a sound of their own. + this._contentScope.get().sendAsyncMessage('AccessFu:Present', + Presentation.actionInvoked(aAccessible, 'click')); + } + }; + + let focusedAcc = Utils.AccService.getAccessibleFor( + this.document.activeElement); + if (focusedAcc && this.vc.position === focusedAcc + && focusedAcc.role === Roles.ENTRY) { + let accText = focusedAcc.QueryInterface(Ci.nsIAccessibleText); + let oldOffset = accText.caretOffset; + let newOffset = aMessage.json.offset; + let text = accText.getText(0, accText.characterCount); + + if (newOffset >= 0 && newOffset <= accText.characterCount) { + accText.caretOffset = newOffset; + } + + this.presentCaretChange(text, oldOffset, accText.caretOffset); + return; + } + + // recursively find a descendant that is activatable. + let getActivatableDescendant = (aAccessible) => { + if (aAccessible.actionCount > 0) { + return aAccessible; + } + + for (let acc = aAccessible.firstChild; acc; acc = acc.nextSibling) { + let activatable = getActivatableDescendant(acc); + if (activatable) { + return activatable; + } + } + + return null; + }; + + let vc = this.vc; + if (!this.sendToChild(vc, aMessage, null, true)) { + let position = vc.position; + activateAccessible(getActivatableDescendant(position) || position); + } + }, + + adjustRange: function cc_adjustRange(aAccessible, aStepUp) { + let acc = Utils.getEmbeddedControl(aAccessible) || aAccessible; + try { + acc.QueryInterface(Ci.nsIAccessibleValue); + } catch (x) { + // This is not an adjustable, return false. + return false; + } + + let elem = acc.DOMNode; + if (!elem) { + return false; + } + + if (elem.tagName === 'INPUT' && elem.type === 'range') { + elem[aStepUp ? 'stepDown' : 'stepUp'](); + let evt = this.document.createEvent('UIEvent'); + evt.initEvent('change', true, true); + elem.dispatchEvent(evt); + } else { + let evt = this.document.createEvent('KeyboardEvent'); + let keycode = aStepUp ? evt.DOM_VK_DOWN : evt.DOM_VK_UP; + evt.initKeyEvent( + "keypress", false, true, null, false, false, false, false, keycode, 0); + elem.dispatchEvent(evt); + } + + return true; + }, + + handleMoveByGranularity: function cc_handleMoveByGranularity(aMessage) { + // XXX: Add sendToChild. Right now this is only used in Android, so no need. + let direction = aMessage.json.direction; + let granularity; + + switch(aMessage.json.granularity) { + case MOVEMENT_GRANULARITY_CHARACTER: + granularity = Ci.nsIAccessiblePivot.CHAR_BOUNDARY; + break; + case MOVEMENT_GRANULARITY_WORD: + granularity = Ci.nsIAccessiblePivot.WORD_BOUNDARY; + break; + default: + return; + } + + if (direction === 'Previous') { + this.vc.movePreviousByText(granularity); + } else if (direction === 'Next') { + this.vc.moveNextByText(granularity); + } + }, + + presentCaretChange: function cc_presentCaretChange( + aText, aOldOffset, aNewOffset) { + if (aOldOffset !== aNewOffset) { + let msg = Presentation.textSelectionChanged(aText, aNewOffset, aNewOffset, + aOldOffset, aOldOffset, true); + this._contentScope.get().sendAsyncMessage('AccessFu:Present', msg); + } + }, + + handleMoveCaret: function cc_handleMoveCaret(aMessage) { + let direction = aMessage.json.direction; + let granularity = aMessage.json.granularity; + let accessible = this.vc.position; + let accText = accessible.QueryInterface(Ci.nsIAccessibleText); + let oldOffset = accText.caretOffset; + let text = accText.getText(0, accText.characterCount); + + let start = {}, end = {}; + if (direction === 'Previous' && !aMessage.json.atStart) { + switch (granularity) { + case MOVEMENT_GRANULARITY_CHARACTER: + accText.caretOffset--; + break; + case MOVEMENT_GRANULARITY_WORD: + accText.getTextBeforeOffset(accText.caretOffset, + Ci.nsIAccessibleText.BOUNDARY_WORD_START, start, end); + accText.caretOffset = end.value === accText.caretOffset ? + start.value : end.value; + break; + case MOVEMENT_GRANULARITY_PARAGRAPH: + let startOfParagraph = text.lastIndexOf('\n', accText.caretOffset - 1); + accText.caretOffset = startOfParagraph !== -1 ? startOfParagraph : 0; + break; + } + } else if (direction === 'Next' && !aMessage.json.atEnd) { + switch (granularity) { + case MOVEMENT_GRANULARITY_CHARACTER: + accText.caretOffset++; + break; + case MOVEMENT_GRANULARITY_WORD: + accText.getTextAtOffset(accText.caretOffset, + Ci.nsIAccessibleText.BOUNDARY_WORD_END, start, end); + accText.caretOffset = end.value; + break; + case MOVEMENT_GRANULARITY_PARAGRAPH: + accText.caretOffset = text.indexOf('\n', accText.caretOffset + 1); + break; + } + } + + this.presentCaretChange(text, oldOffset, accText.caretOffset); + }, + + getChildCursor: function cc_getChildCursor(aAccessible) { + let acc = aAccessible || this.vc.position; + if (Utils.isAliveAndVisible(acc) && acc.role === Roles.INTERNAL_FRAME) { + let domNode = acc.DOMNode; + let mm = this._childMessageSenders.get(domNode, null); + if (!mm) { + mm = Utils.getMessageManager(domNode); + mm.addWeakMessageListener('AccessFu:MoveCursor', this); + this._childMessageSenders.set(domNode, mm); + } + + return mm; + } + + return null; + }, + + sendToChild: function cc_sendToChild(aVirtualCursor, aMessage, aReplacer, + aFocus) { + let position = aVirtualCursor.position; + let mm = this.getChildCursor(position); + if (!mm) { + return false; + } + + if (aFocus) { + position.takeFocus(); + } + + // XXX: This is a silly way to make a deep copy + let newJSON = JSON.parse(JSON.stringify(aMessage.json)); + newJSON.origin = 'parent'; + for (let attr in aReplacer) { + newJSON[attr] = aReplacer[attr]; + } + + mm.sendAsyncMessage(aMessage.name, newJSON); + return true; + }, + + sendToParent: function cc_sendToParent(aMessage) { + // XXX: This is a silly way to make a deep copy + let newJSON = JSON.parse(JSON.stringify(aMessage.json)); + newJSON.origin = 'child'; + aMessage.target.sendAsyncMessage(aMessage.name, newJSON); + }, + + /** + * Move cursor and/or present its location. + * aOptions could have any of these fields: + * - delay: in ms, before actual move is performed. Another autoMove call + * would cancel it. Useful if we want to wait for a possible trailing + * focus move. Default 0. + * - noOpIfOnScreen: if accessible is alive and visible, don't do anything. + * - forcePresent: present cursor location, whether we move or don't. + * - moveToFocused: if there is a focused accessible move to that. This takes + * precedence over given anchor. + * - moveMethod: pivot move method to use, default is 'moveNext', + */ + autoMove: function cc_autoMove(aAnchor, aOptions = {}) { + this.cancelAutoMove(); + + let moveFunc = () => { + let vc = this.vc; + let acc = aAnchor; + let rule = aOptions.onScreenOnly ? + TraversalRules.SimpleOnScreen : TraversalRules.Simple; + let forcePresentFunc = () => { + if (aOptions.forcePresent) { + this._contentScope.get().sendAsyncMessage( + 'AccessFu:Present', Presentation.pivotChanged( + vc.position, null, Ci.nsIAccessiblePivot.REASON_NONE, + vc.startOffset, vc.endOffset, false)); + } + }; + + if (aOptions.noOpIfOnScreen && + Utils.isAliveAndVisible(vc.position, true)) { + forcePresentFunc(); + return; + } + + if (aOptions.moveToFocused) { + acc = Utils.AccService.getAccessibleFor( + this.document.activeElement) || acc; + } + + let moved = false; + let moveMethod = aOptions.moveMethod || 'moveNext'; // default is moveNext + let moveFirstOrLast = moveMethod in ['moveFirst', 'moveLast']; + if (!moveFirstOrLast || acc) { + // We either need next/previous or there is an anchor we need to use. + moved = vc[moveFirstOrLast ? 'moveNext' : moveMethod](rule, acc, true, + false); + } + if (moveFirstOrLast && !moved) { + // We move to first/last after no anchor move happened or succeeded. + moved = vc[moveMethod](rule, false); + } + + let sentToChild = this.sendToChild(vc, { + name: 'AccessFu:AutoMove', + json: { + moveMethod: aOptions.moveMethod, + moveToFocused: aOptions.moveToFocused, + noOpIfOnScreen: true, + forcePresent: true + } + }, null, true); + + if (!moved && !sentToChild) { + forcePresentFunc(); + } + }; + + if (aOptions.delay) { + this._autoMove = this.window.setTimeout(moveFunc, aOptions.delay); + } else { + moveFunc(); + } + }, + + cancelAutoMove: function cc_cancelAutoMove() { + this.window.clearTimeout(this._autoMove); + this._autoMove = 0; + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsISupportsWeakReference, + Ci.nsIMessageListener + ]) +}; diff --git a/accessible/jsat/EventManager.jsm b/accessible/jsat/EventManager.jsm new file mode 100644 index 000000000..4d635eb68 --- /dev/null +++ b/accessible/jsat/EventManager.jsm @@ -0,0 +1,723 @@ +/* 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; + } + } +}; diff --git a/accessible/jsat/Gestures.jsm b/accessible/jsat/Gestures.jsm new file mode 100644 index 000000000..cc431614c --- /dev/null +++ b/accessible/jsat/Gestures.jsm @@ -0,0 +1,956 @@ +/* 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/. */ + +/* global Components, GestureSettings, XPCOMUtils, Utils, Promise, Logger */ +/* exported GestureSettings, GestureTracker */ + +/****************************************************************************** + All gestures have the following pathways when being resolved(v)/rejected(x): + Tap -> DoubleTap (x) + -> Dwell (x) + -> Swipe (x) + + DoubleTap -> TripleTap (x) + -> TapHold (x) + + TripleTap -> DoubleTapHold (x) + + Dwell -> DwellEnd (v) + + Swipe -> Explore (x) + + TapHold -> TapHoldEnd (v) + + DoubleTapHold -> DoubleTapHoldEnd (v) + + DwellEnd -> Explore (x) + + TapHoldEnd -> Explore (x) + + DoubleTapHoldEnd -> Explore (x) + + ExploreEnd -> Explore (x) + + Explore -> ExploreEnd (v) +******************************************************************************/ + +'use strict'; + +const Cu = Components.utils; + +this.EXPORTED_SYMBOLS = ['GestureSettings', 'GestureTracker']; // jshint ignore:line + +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); + +XPCOMUtils.defineLazyModuleGetter(this, 'Utils', // jshint ignore:line + 'resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Logger', // jshint ignore:line + 'resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'setTimeout', // jshint ignore:line + 'resource://gre/modules/Timer.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'clearTimeout', // jshint ignore:line + 'resource://gre/modules/Timer.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Promise', // jshint ignore:line + 'resource://gre/modules/Promise.jsm'); + +// Default maximum duration of swipe +const SWIPE_MAX_DURATION = 200; +// Default maximum amount of time allowed for a gesture to be considered a +// multitouch +const MAX_MULTITOUCH = 125; +// Default maximum consecutive pointer event timeout +const MAX_CONSECUTIVE_GESTURE_DELAY = 200; +// Default delay before tap turns into dwell +const DWELL_THRESHOLD = 250; +// Minimal swipe distance in inches +const SWIPE_MIN_DISTANCE = 0.4; +// Maximum distance the pointer could move during a tap in inches +const TAP_MAX_RADIUS = 0.2; +// Directness coefficient. It is based on the maximum 15 degree angle between +// consequent pointer move lines. +const DIRECTNESS_COEFF = 1.44; +// Amount in inches from the edges of the screen for it to be an edge swipe +const EDGE = 0.1; +// Multiply timeouts by this constant, x2 works great too for slower users. +const TIMEOUT_MULTIPLIER = 1; +// A single pointer down/up sequence periodically precedes the tripple swipe +// gesture on Android. This delay acounts for that. +const IS_ANDROID = Utils.MozBuildApp === 'mobile/android' && + Utils.AndroidSdkVersion >= 14; + +/** + * A point object containing distance travelled data. + * @param {Object} aPoint A point object that looks like: { + * x: x coordinate in pixels, + * y: y coordinate in pixels + * } + */ +function Point(aPoint) { + this.startX = this.x = aPoint.x; + this.startY = this.y = aPoint.y; + this.distanceTraveled = 0; + this.totalDistanceTraveled = 0; +} + +Point.prototype = { + /** + * Update the current point coordiates. + * @param {Object} aPoint A new point coordinates. + */ + update: function Point_update(aPoint) { + let lastX = this.x; + let lastY = this.y; + this.x = aPoint.x; + this.y = aPoint.y; + this.distanceTraveled = this.getDistanceToCoord(lastX, lastY); + this.totalDistanceTraveled += this.distanceTraveled; + }, + + reset: function Point_reset() { + this.distanceTraveled = 0; + this.totalDistanceTraveled = 0; + }, + + /** + * Get distance between the current point coordinates and the given ones. + * @param {Number} aX A pixel value for the x coordinate. + * @param {Number} aY A pixel value for the y coordinate. + * @return {Number} A distance between point's current and the given + * coordinates. + */ + getDistanceToCoord: function Point_getDistanceToCoord(aX, aY) { + return Math.hypot(this.x - aX, this.y - aY); + }, + + /** + * Get the direct distance travelled by the point so far. + */ + get directDistanceTraveled() { + return this.getDistanceToCoord(this.startX, this.startY); + } +}; + +/** + * An externally accessible collection of settings used in gesture resolition. + * @type {Object} + */ +this.GestureSettings = { // jshint ignore:line + /** + * Maximum duration of swipe + * @type {Number} + */ + swipeMaxDuration: SWIPE_MAX_DURATION * TIMEOUT_MULTIPLIER, + + /** + * Maximum amount of time allowed for a gesture to be considered a multitouch. + * @type {Number} + */ + maxMultitouch: MAX_MULTITOUCH * TIMEOUT_MULTIPLIER, + + /** + * Maximum consecutive pointer event timeout. + * @type {Number} + */ + maxConsecutiveGestureDelay: + MAX_CONSECUTIVE_GESTURE_DELAY * TIMEOUT_MULTIPLIER, + + /** + * A maximum time we wait for a next pointer down event to consider a sequence + * a multi-action gesture. + * @type {Number} + */ + maxGestureResolveTimeout: + MAX_CONSECUTIVE_GESTURE_DELAY * TIMEOUT_MULTIPLIER, + + /** + * Delay before tap turns into dwell + * @type {Number} + */ + dwellThreshold: DWELL_THRESHOLD * TIMEOUT_MULTIPLIER, + + /** + * Minimum distance that needs to be travelled for the pointer move to be + * fired. + * @type {Number} + */ + travelThreshold: 0.025 +}; + +/** + * An interface that handles the pointer events and calculates the appropriate + * gestures. + * @type {Object} + */ +this.GestureTracker = { // jshint ignore:line + /** + * Reset GestureTracker to its initial state. + * @return {[type]} [description] + */ + reset: function GestureTracker_reset() { + if (this.current) { + this.current.clearTimer(); + } + delete this.current; + }, + + /** + * Create a new gesture object and attach resolution handler to it as well as + * handle the incoming pointer event. + * @param {Object} aDetail A new pointer event detail. + * @param {Number} aTimeStamp A new pointer event timeStamp. + * @param {Function} aGesture A gesture constructor (default: Tap). + */ + _init: function GestureTracker__init(aDetail, aTimeStamp, aGesture) { + // Only create a new gesture on |pointerdown| event. + if (aDetail.type !== 'pointerdown') { + return; + } + let GestureConstructor = aGesture || (IS_ANDROID ? DoubleTap : Tap); + this._create(GestureConstructor); + this._update(aDetail, aTimeStamp); + }, + + /** + * Handle the incoming pointer event with the existing gesture object(if + * present) or with the newly created one. + * @param {Object} aDetail A new pointer event detail. + * @param {Number} aTimeStamp A new pointer event timeStamp. + */ + handle: function GestureTracker_handle(aDetail, aTimeStamp) { + Logger.gesture(() => { + return ['Pointer event', Utils.dpi, 'at:', aTimeStamp, JSON.stringify(aDetail)]; + }); + this[this.current ? '_update' : '_init'](aDetail, aTimeStamp); + }, + + /** + * Create a new gesture object and attach resolution handler to it. + * @param {Function} aGesture A gesture constructor. + * @param {Number} aTimeStamp An original pointer event timeStamp. + * @param {Array} aPoints All changed points associated with the new pointer + * event. + * @param {?String} aLastEvent Last pointer event type. + */ + _create: function GestureTracker__create(aGesture, aTimeStamp, aPoints, aLastEvent) { + this.current = new aGesture(aTimeStamp, aPoints, aLastEvent); /* A constructor name should start with an uppercase letter. */ // jshint ignore:line + this.current.then(this._onFulfill.bind(this)); + }, + + /** + * Handle the incoming pointer event with the existing gesture object. + * @param {Object} aDetail A new pointer event detail. + * @param {Number} aTimeStamp A new pointer event timeStamp. + */ + _update: function GestureTracker_update(aDetail, aTimeStamp) { + this.current[aDetail.type](aDetail.points, aTimeStamp); + }, + + /** + * A resolution handler function for the current gesture promise. + * @param {Object} aResult A resolution payload with the relevant gesture id + * and an optional new gesture contructor. + */ + _onFulfill: function GestureTracker__onFulfill(aResult) { + let {id, gestureType} = aResult; + let current = this.current; + // Do nothing if there's no existing gesture or there's already a newer + // gesture. + if (!current || current.id !== id) { + return; + } + // Only create a gesture if we got a constructor. + if (gestureType) { + this._create(gestureType, current.startTime, current.points, + current.lastEvent); + } else { + this.current.clearTimer(); + delete this.current; + } + } +}; + +/** + * Compile a mozAccessFuGesture detail structure. + * @param {String} aType A gesture type. + * @param {Object} aPoints Gesture's points. + * @param {String} xKey A default key for the x coordinate. Default is + * 'startX'. + * @param {String} yKey A default key for the y coordinate. Default is + * 'startY'. + * @return {Object} a mozAccessFuGesture detail structure. + */ +function compileDetail(aType, aPoints, keyMap = {x: 'startX', y: 'startY'}) { + let touches = []; + let maxDeltaX = 0; + let maxDeltaY = 0; + for (let identifier in aPoints) { + let point = aPoints[identifier]; + let touch = {}; + for (let key in keyMap) { + touch[key] = point[keyMap[key]]; + } + touches.push(touch); + let deltaX = point.x - point.startX; + let deltaY = point.y - point.startY; + // Determine the maximum x and y travel intervals. + if (Math.abs(maxDeltaX) < Math.abs(deltaX)) { + maxDeltaX = deltaX; + } + if (Math.abs(maxDeltaY) < Math.abs(deltaY)) { + maxDeltaY = deltaY; + } + // Since the gesture is resolving, reset the points' distance information + // since they are passed to the next potential gesture. + point.reset(); + } + return { + type: aType, + touches: touches, + deltaX: maxDeltaX, + deltaY: maxDeltaY + }; +} + +/** + * A general gesture object. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * Default is an empty object. + * @param {?String} aLastEvent Last pointer event type. + */ +function Gesture(aTimeStamp, aPoints = {}, aLastEvent = undefined) { + this.startTime = Date.now(); + Logger.gesture('Creating', this.id, 'gesture.'); + this.points = aPoints; + this.lastEvent = aLastEvent; + this._deferred = Promise.defer(); + // Call this._handleResolve or this._handleReject when the promise is + // fulfilled with either resolve or reject. + this.promise = this._deferred.promise.then(this._handleResolve.bind(this), + this._handleReject.bind(this)); + this.startTimer(aTimeStamp); +} + +Gesture.prototype = { + /** + * Get the gesture timeout delay. + * @return {Number} + */ + _getDelay: function Gesture__getDelay() { + // If nothing happens withing the + // GestureSettings.maxConsecutiveGestureDelay, we should not wait for any + // more pointer events and consider them the part of the same gesture - + // reject this gesture promise. + return GestureSettings.maxConsecutiveGestureDelay; + }, + + /** + * Clear the existing timer. + */ + clearTimer: function Gesture_clearTimer() { + Logger.gesture('clearTimeout', this.type); + clearTimeout(this._timer); + delete this._timer; + }, + + /** + * Start the timer for gesture timeout. + * @param {Number} aTimeStamp An original pointer event's timeStamp that + * started the gesture resolution sequence. + */ + startTimer: function Gesture_startTimer(aTimeStamp) { + Logger.gesture('startTimer', this.type); + this.clearTimer(); + let delay = this._getDelay(aTimeStamp); + let handler = () => { + Logger.gesture('timer handler'); + this.clearTimer(); + if (!this._inProgress) { + this._deferred.reject(); + } else if (this._rejectToOnWait) { + this._deferred.reject(this._rejectToOnWait); + } + }; + if (delay <= 0) { + handler(); + } else { + this._timer = setTimeout(handler, delay); + } + }, + + /** + * Add a gesture promise resolution callback. + * @param {Function} aCallback + */ + then: function Gesture_then(aCallback) { + this.promise.then(aCallback); + }, + + /** + * Update gesture's points. Test the points set with the optional gesture test + * function. + * @param {Array} aPoints An array with the changed points from the new + * pointer event. + * @param {String} aType Pointer event type. + * @param {Boolean} aCanCreate A flag that enables including the new points. + * Default is false. + * @param {Boolean} aNeedComplete A flag that indicates that the gesture is + * completing. Default is false. + * @return {Boolean} Indicates whether the gesture can be complete (it is + * set to true iff the aNeedComplete is true and there was a change to at + * least one point that belongs to the gesture). + */ + _update: function Gesture__update(aPoints, aType, aCanCreate = false, aNeedComplete = false) { + let complete; + let lastEvent; + for (let point of aPoints) { + let identifier = point.identifier; + let gesturePoint = this.points[identifier]; + if (gesturePoint) { + if (aType === 'pointerdown' && aCanCreate) { + // scratch the previous pointer with that id. + this.points[identifier] = new Point(point); + } else { + gesturePoint.update(point); + } + if (aNeedComplete) { + // Since the gesture is completing and at least one of the gesture + // points is updated, set the return value to true. + complete = true; + } + lastEvent = lastEvent || aType; + } else if (aCanCreate) { + // Only create a new point if aCanCreate is true. + this.points[identifier] = + new Point(point); + lastEvent = lastEvent || aType; + } + } + this.lastEvent = lastEvent || this.lastEvent; + // If test function is defined test the points. + if (this.test) { + this.test(complete); + } + return complete; + }, + + /** + * Emit a mozAccessFuGesture (when the gesture is resolved). + * @param {Object} aDetail a compiled mozAccessFuGesture detail structure. + */ + _emit: function Gesture__emit(aDetail) { + let evt = new Utils.win.CustomEvent('mozAccessFuGesture', { + bubbles: true, + cancelable: true, + detail: aDetail + }); + Utils.win.dispatchEvent(evt); + }, + + /** + * Handle the pointer down event. + * @param {Array} aPoints A new pointer down points. + * @param {Number} aTimeStamp A new pointer down timeStamp. + */ + pointerdown: function Gesture_pointerdown(aPoints, aTimeStamp) { + this._inProgress = true; + this._update(aPoints, 'pointerdown', + aTimeStamp - this.startTime < GestureSettings.maxMultitouch); + }, + + /** + * Handle the pointer move event. + * @param {Array} aPoints A new pointer move points. + */ + pointermove: function Gesture_pointermove(aPoints) { + this._update(aPoints, 'pointermove'); + }, + + /** + * Handle the pointer up event. + * @param {Array} aPoints A new pointer up points. + */ + pointerup: function Gesture_pointerup(aPoints) { + let complete = this._update(aPoints, 'pointerup', false, true); + if (complete) { + this._deferred.resolve(); + } + }, + + /** + * A subsequent gesture constructor to resolve the current one to. E.g. + * tap->doubletap, dwell->dwellend, etc. + * @type {Function} + */ + resolveTo: null, + + /** + * A unique id for the gesture. Composed of the type + timeStamp. + */ + get id() { + delete this._id; + this._id = this.type + this.startTime; + return this._id; + }, + + /** + * A gesture promise resolve callback. Compile and emit the gesture. + * @return {Object} Returns a structure to the gesture handler that looks like + * this: { + * id: current gesture id, + * gestureType: an optional subsequent gesture constructor. + * } + */ + _handleResolve: function Gesture__handleResolve() { + if (this.isComplete) { + return; + } + Logger.gesture('Resolving', this.id, 'gesture.'); + this.isComplete = true; + this.clearTimer(); + let detail = this.compile(); + if (detail) { + this._emit(detail); + } + return { + id: this.id, + gestureType: this.resolveTo + }; + }, + + /** + * A gesture promise reject callback. + * @return {Object} Returns a structure to the gesture handler that looks like + * this: { + * id: current gesture id, + * gestureType: an optional subsequent gesture constructor. + * } + */ + _handleReject: function Gesture__handleReject(aRejectTo) { + if (this.isComplete) { + return; + } + Logger.gesture('Rejecting', this.id, 'gesture.'); + this.isComplete = true; + this.clearTimer(); + return { + id: this.id, + gestureType: aRejectTo + }; + }, + + /** + * A default compilation function used to build the mozAccessFuGesture event + * detail. The detail always includes the type and the touches associated + * with the gesture. + * @return {Object} Gesture event detail. + */ + compile: function Gesture_compile() { + return compileDetail(this.type, this.points); + } +}; + +/** + * A mixin for an explore related object. + */ +function ExploreGesture() { + this.compile = () => { + // Unlike most of other gestures explore based gestures compile using the + // current point position and not the start one. + return compileDetail(this.type, this.points, {x: 'x', y: 'y'}); + }; +} + +/** + * Check the in progress gesture for completion. + */ +function checkProgressGesture(aGesture) { + aGesture._inProgress = true; + if (aGesture.lastEvent === 'pointerup') { + if (aGesture.test) { + aGesture.test(true); + } + aGesture._deferred.resolve(); + } +} + +/** + * A common travel gesture. When the travel gesture is created, all subsequent + * pointer events' points are tested for their total distance traveled. If that + * distance exceeds the _threshold distance, the gesture will be rejected to a + * _travelTo gesture. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + * @param {Function} aTravelTo A contructor for the gesture to reject to when + * travelling (default: Explore). + * @param {Number} aThreshold Travel threshold (default: + * GestureSettings.travelThreshold). + */ +function TravelGesture(aTimeStamp, aPoints, aLastEvent, aTravelTo = Explore, aThreshold = GestureSettings.travelThreshold) { + Gesture.call(this, aTimeStamp, aPoints, aLastEvent); + this._travelTo = aTravelTo; + this._threshold = aThreshold; +} + +TravelGesture.prototype = Object.create(Gesture.prototype); + +/** + * Test the gesture points for travel. The gesture will be rejected to + * this._travelTo gesture iff at least one point crosses this._threshold. + */ +TravelGesture.prototype.test = function TravelGesture_test() { + if (!this._travelTo) { + return; + } + for (let identifier in this.points) { + let point = this.points[identifier]; + if (point.totalDistanceTraveled / Utils.dpi > this._threshold) { + this._deferred.reject(this._travelTo); + return; + } + } +}; + +/** + * DwellEnd gesture. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function DwellEnd(aTimeStamp, aPoints, aLastEvent) { + this._inProgress = true; + // If the pointer travels, reject to Explore. + TravelGesture.call(this, aTimeStamp, aPoints, aLastEvent); + checkProgressGesture(this); +} + +DwellEnd.prototype = Object.create(TravelGesture.prototype); +DwellEnd.prototype.type = 'dwellend'; + +/** + * TapHoldEnd gesture. This gesture can be represented as the following diagram: + * pointerdown-pointerup-pointerdown-*wait*-pointerup. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function TapHoldEnd(aTimeStamp, aPoints, aLastEvent) { + this._inProgress = true; + // If the pointer travels, reject to Explore. + TravelGesture.call(this, aTimeStamp, aPoints, aLastEvent); + checkProgressGesture(this); +} + +TapHoldEnd.prototype = Object.create(TravelGesture.prototype); +TapHoldEnd.prototype.type = 'tapholdend'; + +/** + * DoubleTapHoldEnd gesture. This gesture can be represented as the following + * diagram: + * pointerdown-pointerup-pointerdown-pointerup-pointerdown-*wait*-pointerup. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function DoubleTapHoldEnd(aTimeStamp, aPoints, aLastEvent) { + this._inProgress = true; + // If the pointer travels, reject to Explore. + TravelGesture.call(this, aTimeStamp, aPoints, aLastEvent); + checkProgressGesture(this); +} + +DoubleTapHoldEnd.prototype = Object.create(TravelGesture.prototype); +DoubleTapHoldEnd.prototype.type = 'doubletapholdend'; + +/** + * A common tap gesture object. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + * @param {Function} aRejectToOnWait A constructor for the next gesture to + * reject to in case no pointermove or pointerup happens within the + * GestureSettings.dwellThreshold. + * @param {Function} aTravelTo An optional constuctor for the next gesture to + * reject to in case the the TravelGesture test fails. + * @param {Function} aRejectToOnPointerDown A constructor for the gesture to + * reject to if a finger comes down immediately after the tap. + */ +function TapGesture(aTimeStamp, aPoints, aLastEvent, aRejectToOnWait, aTravelTo, aRejectToOnPointerDown) { + this._rejectToOnWait = aRejectToOnWait; + this._rejectToOnPointerDown = aRejectToOnPointerDown; + // If the pointer travels, reject to aTravelTo. + TravelGesture.call(this, aTimeStamp, aPoints, aLastEvent, aTravelTo, + TAP_MAX_RADIUS); +} + +TapGesture.prototype = Object.create(TravelGesture.prototype); +TapGesture.prototype._getDelay = function TapGesture__getDelay() { + // If, for TapGesture, no pointermove or pointerup happens within the + // GestureSettings.dwellThreshold, reject. + // Note: the original pointer event's timeStamp is irrelevant here. + return GestureSettings.dwellThreshold; +}; + +TapGesture.prototype.pointerup = function TapGesture_pointerup(aPoints) { + if (this._rejectToOnPointerDown) { + let complete = this._update(aPoints, 'pointerup', false, true); + if (complete) { + this.clearTimer(); + if (GestureSettings.maxGestureResolveTimeout) { + this._pointerUpTimer = setTimeout(() => { + clearTimeout(this._pointerUpTimer); + delete this._pointerUpTimer; + this._deferred.resolve(); + }, GestureSettings.maxGestureResolveTimeout); + } else { + this._deferred.resolve(); + } + } + } else { + TravelGesture.prototype.pointerup.call(this, aPoints); + } +}; + +TapGesture.prototype.pointerdown = function TapGesture_pointerdown(aPoints, aTimeStamp) { + if (this._pointerUpTimer) { + clearTimeout(this._pointerUpTimer); + delete this._pointerUpTimer; + this._deferred.reject(this._rejectToOnPointerDown); + } else { + TravelGesture.prototype.pointerdown.call(this, aPoints, aTimeStamp); + } +}; + + +/** + * Tap gesture. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function Tap(aTimeStamp, aPoints, aLastEvent) { + // If the pointer travels, reject to Swipe. + TapGesture.call(this, aTimeStamp, aPoints, aLastEvent, Dwell, Swipe, DoubleTap); +} + +Tap.prototype = Object.create(TapGesture.prototype); +Tap.prototype.type = 'tap'; + + +/** + * Double Tap gesture. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function DoubleTap(aTimeStamp, aPoints, aLastEvent) { + this._inProgress = true; + TapGesture.call(this, aTimeStamp, aPoints, aLastEvent, TapHold, null, TripleTap); +} + +DoubleTap.prototype = Object.create(TapGesture.prototype); +DoubleTap.prototype.type = 'doubletap'; + +/** + * Triple Tap gesture. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function TripleTap(aTimeStamp, aPoints, aLastEvent) { + this._inProgress = true; + TapGesture.call(this, aTimeStamp, aPoints, aLastEvent, DoubleTapHold, null, null); +} + +TripleTap.prototype = Object.create(TapGesture.prototype); +TripleTap.prototype.type = 'tripletap'; + +/** + * Common base object for gestures that are created as resolved. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function ResolvedGesture(aTimeStamp, aPoints, aLastEvent) { + Gesture.call(this, aTimeStamp, aPoints, aLastEvent); + // Resolve the guesture right away. + this._deferred.resolve(); +} + +ResolvedGesture.prototype = Object.create(Gesture.prototype); + +/** + * Dwell gesture + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function Dwell(aTimeStamp, aPoints, aLastEvent) { + ResolvedGesture.call(this, aTimeStamp, aPoints, aLastEvent); +} + +Dwell.prototype = Object.create(ResolvedGesture.prototype); +Dwell.prototype.type = 'dwell'; +Dwell.prototype.resolveTo = DwellEnd; + +/** + * TapHold gesture + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function TapHold(aTimeStamp, aPoints, aLastEvent) { + ResolvedGesture.call(this, aTimeStamp, aPoints, aLastEvent); +} + +TapHold.prototype = Object.create(ResolvedGesture.prototype); +TapHold.prototype.type = 'taphold'; +TapHold.prototype.resolveTo = TapHoldEnd; + +/** + * DoubleTapHold gesture + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function DoubleTapHold(aTimeStamp, aPoints, aLastEvent) { + ResolvedGesture.call(this, aTimeStamp, aPoints, aLastEvent); +} + +DoubleTapHold.prototype = Object.create(ResolvedGesture.prototype); +DoubleTapHold.prototype.type = 'doubletaphold'; +DoubleTapHold.prototype.resolveTo = DoubleTapHoldEnd; + +/** + * Explore gesture + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function Explore(aTimeStamp, aPoints, aLastEvent) { + ExploreGesture.call(this); + ResolvedGesture.call(this, aTimeStamp, aPoints, aLastEvent); +} + +Explore.prototype = Object.create(ResolvedGesture.prototype); +Explore.prototype.type = 'explore'; +Explore.prototype.resolveTo = ExploreEnd; + +/** + * ExploreEnd gesture. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function ExploreEnd(aTimeStamp, aPoints, aLastEvent) { + this._inProgress = true; + ExploreGesture.call(this); + // If the pointer travels, reject to Explore. + TravelGesture.call(this, aTimeStamp, aPoints, aLastEvent); + checkProgressGesture(this); +} + +ExploreEnd.prototype = Object.create(TravelGesture.prototype); +ExploreEnd.prototype.type = 'exploreend'; + +/** + * Swipe gesture. + * @param {Number} aTimeStamp An original pointer event's timeStamp that started + * the gesture resolution sequence. + * @param {Object} aPoints An existing set of points (from previous events). + * @param {?String} aLastEvent Last pointer event type. + */ +function Swipe(aTimeStamp, aPoints, aLastEvent) { + this._inProgress = true; + this._rejectToOnWait = Explore; + Gesture.call(this, aTimeStamp, aPoints, aLastEvent); + checkProgressGesture(this); +} + +Swipe.prototype = Object.create(Gesture.prototype); +Swipe.prototype.type = 'swipe'; +Swipe.prototype._getDelay = function Swipe__getDelay(aTimeStamp) { + // Swipe should be completed within the GestureSettings.swipeMaxDuration from + // the initial pointer down event. + return GestureSettings.swipeMaxDuration - this.startTime + aTimeStamp; +}; + +/** + * Determine wither the gesture was Swipe or Explore. + * @param {Booler} aComplete A flag that indicates whether the gesture is and + * will be complete after the test. + */ +Swipe.prototype.test = function Swipe_test(aComplete) { + if (!aComplete) { + // No need to test if the gesture is not completing or can't be complete. + return; + } + let reject = true; + // If at least one point travelled for more than SWIPE_MIN_DISTANCE and it was + // direct enough, consider it a Swipe. + for (let identifier in this.points) { + let point = this.points[identifier]; + let directDistance = point.directDistanceTraveled; + if (directDistance / Utils.dpi >= SWIPE_MIN_DISTANCE || + directDistance * DIRECTNESS_COEFF >= point.totalDistanceTraveled) { + reject = false; + } + } + if (reject) { + this._deferred.reject(Explore); + } +}; + +/** + * Compile a swipe related mozAccessFuGesture event detail. + * @return {Object} A mozAccessFuGesture detail object. + */ +Swipe.prototype.compile = function Swipe_compile() { + let type = this.type; + let detail = compileDetail(type, this.points, + {x1: 'startX', y1: 'startY', x2: 'x', y2: 'y'}); + let deltaX = detail.deltaX; + let deltaY = detail.deltaY; + let edge = EDGE * Utils.dpi; + if (Math.abs(deltaX) > Math.abs(deltaY)) { + // Horizontal swipe. + let startPoints = detail.touches.map(touch => touch.x1); + if (deltaX > 0) { + detail.type = type + 'right'; + detail.edge = Math.min.apply(null, startPoints) <= edge; + } else { + detail.type = type + 'left'; + detail.edge = + Utils.win.screen.width - Math.max.apply(null, startPoints) <= edge; + } + } else { + // Vertical swipe. + let startPoints = detail.touches.map(touch => touch.y1); + if (deltaY > 0) { + detail.type = type + 'down'; + detail.edge = Math.min.apply(null, startPoints) <= edge; + } else { + detail.type = type + 'up'; + detail.edge = + Utils.win.screen.height - Math.max.apply(null, startPoints) <= edge; + } + } + return detail; +}; diff --git a/accessible/jsat/OutputGenerator.jsm b/accessible/jsat/OutputGenerator.jsm new file mode 100644 index 000000000..36b43a569 --- /dev/null +++ b/accessible/jsat/OutputGenerator.jsm @@ -0,0 +1,1003 @@ +/* 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/. */ + +/* global Components, XPCOMUtils, Utils, PrefCache, States, Roles, Logger */ +/* exported UtteranceGenerator, BrailleGenerator */ + +'use strict'; + +const {utils: Cu, interfaces: Ci} = Components; + +const INCLUDE_DESC = 0x01; +const INCLUDE_NAME = 0x02; +const INCLUDE_VALUE = 0x04; +const NAME_FROM_SUBTREE_RULE = 0x10; +const IGNORE_EXPLICIT_NAME = 0x20; + +const OUTPUT_DESC_FIRST = 0; +const OUTPUT_DESC_LAST = 1; + +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Utils', // jshint ignore:line + 'resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'PrefCache', // jshint ignore:line + 'resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Logger', // jshint ignore:line + 'resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Roles', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'States', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); + +this.EXPORTED_SYMBOLS = ['UtteranceGenerator', 'BrailleGenerator']; // jshint ignore:line + +var OutputGenerator = { + + defaultOutputOrder: OUTPUT_DESC_LAST, + + /** + * Generates output for a PivotContext. + * @param {PivotContext} aContext object that generates and caches + * context information for a given accessible and its relationship with + * another accessible. + * @return {Object} An array of speech data. Depending on the utterance order, + * the data describes the context for an accessible object either + * starting from the accessible's ancestry or accessible's subtree. + */ + genForContext: function genForContext(aContext) { + let output = []; + let self = this; + let addOutput = function addOutput(aAccessible) { + output.push.apply(output, self.genForObject(aAccessible, aContext)); + }; + let ignoreSubtree = function ignoreSubtree(aAccessible) { + let roleString = Utils.AccService.getStringRole(aAccessible.role); + let nameRule = self.roleRuleMap[roleString] || 0; + // Ignore subtree if the name is explicit and the role's name rule is the + // NAME_FROM_SUBTREE_RULE. + return (((nameRule & INCLUDE_VALUE) && aAccessible.value) || + ((nameRule & NAME_FROM_SUBTREE_RULE) && + (Utils.getAttributes(aAccessible)['explicit-name'] === 'true' && + !(nameRule & IGNORE_EXPLICIT_NAME)))); + }; + + let contextStart = this._getContextStart(aContext); + + if (this.outputOrder === OUTPUT_DESC_FIRST) { + contextStart.forEach(addOutput); + addOutput(aContext.accessible); + for (let node of aContext.subtreeGenerator(true, ignoreSubtree)) { + addOutput(node); + } + } else { + for (let node of aContext.subtreeGenerator(false, ignoreSubtree)) { + addOutput(node); + } + addOutput(aContext.accessible); + + // If there are any documents in new ancestry, find a first one and place + // it in the beginning of the utterance. + let doc, docIndex = contextStart.findIndex( + ancestor => ancestor.role === Roles.DOCUMENT); + + if (docIndex > -1) { + doc = contextStart.splice(docIndex, 1)[0]; + } + + contextStart.reverse().forEach(addOutput); + if (doc) { + output.unshift.apply(output, self.genForObject(doc, aContext)); + } + } + + return output; + }, + + + /** + * Generates output for an object. + * @param {nsIAccessible} aAccessible accessible object to generate output + * for. + * @param {PivotContext} aContext object that generates and caches + * context information for a given accessible and its relationship with + * another accessible. + * @return {Array} A 2 element array of speech data. The first element + * describes the object and its state. The second element is the object's + * name. Whether the object's description or it's role is included is + * determined by {@link roleRuleMap}. + */ + genForObject: function genForObject(aAccessible, aContext) { + let roleString = Utils.AccService.getStringRole(aAccessible.role); + let func = this.objectOutputFunctions[ + OutputGenerator._getOutputName(roleString)] || + this.objectOutputFunctions.defaultFunc; + + let flags = this.roleRuleMap[roleString] || 0; + + if (aAccessible.childCount === 0) { + flags |= INCLUDE_NAME; + } + + return func.apply(this, [aAccessible, roleString, + Utils.getState(aAccessible), flags, aContext]); + }, + + /** + * Generates output for an action performed. + * @param {nsIAccessible} aAccessible accessible object that the action was + * invoked in. + * @param {string} aActionName the name of the action, one of the keys in + * {@link gActionMap}. + * @return {Array} A one element array with action data. + */ + genForAction: function genForAction(aObject, aActionName) {}, // jshint ignore:line + + /** + * Generates output for an announcement. + * @param {string} aAnnouncement unlocalized announcement. + * @return {Array} An announcement speech data to be localized. + */ + genForAnnouncement: function genForAnnouncement(aAnnouncement) {}, // jshint ignore:line + + /** + * Generates output for a tab state change. + * @param {nsIAccessible} aAccessible accessible object of the tab's attached + * document. + * @param {string} aTabState the tab state name, see + * {@link Presenter.tabStateChanged}. + * @return {Array} The tab state utterace. + */ + genForTabStateChange: function genForTabStateChange(aObject, aTabState) {}, // jshint ignore:line + + /** + * Generates output for announcing entering and leaving editing mode. + * @param {aIsEditing} boolean true if we are in editing mode + * @return {Array} The mode utterance + */ + genForEditingMode: function genForEditingMode(aIsEditing) {}, // jshint ignore:line + + _getContextStart: function getContextStart(aContext) {}, // jshint ignore:line + + /** + * Adds an accessible name and description to the output if available. + * @param {Array} aOutput Output array. + * @param {nsIAccessible} aAccessible current accessible object. + * @param {Number} aFlags output flags. + */ + _addName: function _addName(aOutput, aAccessible, aFlags) { + let name; + if ((Utils.getAttributes(aAccessible)['explicit-name'] === 'true' && + !(aFlags & IGNORE_EXPLICIT_NAME)) || (aFlags & INCLUDE_NAME)) { + name = aAccessible.name; + } + + let description = aAccessible.description; + if (description) { + // Compare against the calculated name unconditionally, regardless of name rule, + // so we can make sure we don't speak duplicated descriptions + let tmpName = name || aAccessible.name; + if (tmpName && (description !== tmpName)) { + name = name || ''; + name = this.outputOrder === OUTPUT_DESC_FIRST ? + description + ' - ' + name : + name + ' - ' + description; + } + } + + if (!name || !name.trim()) { + return; + } + aOutput[this.outputOrder === OUTPUT_DESC_FIRST ? 'push' : 'unshift'](name); + }, + + /** + * Adds a landmark role to the output if available. + * @param {Array} aOutput Output array. + * @param {nsIAccessible} aAccessible current accessible object. + */ + _addLandmark: function _addLandmark(aOutput, aAccessible) { + let landmarkName = Utils.getLandmarkName(aAccessible); + if (!landmarkName) { + return; + } + aOutput[this.outputOrder === OUTPUT_DESC_FIRST ? 'unshift' : 'push']({ + string: landmarkName + }); + }, + + /** + * Adds math roles to the output, for a MathML accessible. + * @param {Array} aOutput Output array. + * @param {nsIAccessible} aAccessible current accessible object. + * @param {String} aRoleStr aAccessible's role string. + */ + _addMathRoles: function _addMathRoles(aOutput, aAccessible, aRoleStr) { + // First, determine the actual role to use (e.g. mathmlfraction). + let roleStr = aRoleStr; + switch(aAccessible.role) { + case Roles.MATHML_CELL: + case Roles.MATHML_ENCLOSED: + case Roles.MATHML_LABELED_ROW: + case Roles.MATHML_ROOT: + case Roles.MATHML_SQUARE_ROOT: + case Roles.MATHML_TABLE: + case Roles.MATHML_TABLE_ROW: + // Use the default role string. + break; + case Roles.MATHML_MULTISCRIPTS: + case Roles.MATHML_OVER: + case Roles.MATHML_SUB: + case Roles.MATHML_SUB_SUP: + case Roles.MATHML_SUP: + case Roles.MATHML_UNDER: + case Roles.MATHML_UNDER_OVER: + // For scripted accessibles, use the string 'mathmlscripted'. + roleStr = 'mathmlscripted'; + break; + case Roles.MATHML_FRACTION: + // From a semantic point of view, the only important point is to + // distinguish between fractions that have a bar and those that do not. + // Per the MathML 3 spec, the latter happens iff the linethickness + // attribute is of the form [zero-float][optional-unit]. In that case, + // we use the string 'mathmlfractionwithoutbar'. + let linethickness = Utils.getAttributes(aAccessible).linethickness; + if (linethickness) { + let numberMatch = linethickness.match(/^(?:\d|\.)+/); + if (numberMatch && !parseFloat(numberMatch[0])) { + roleStr += 'withoutbar'; + } + } + break; + default: + // Otherwise, do not output the actual role. + roleStr = null; + break; + } + + // Get the math role based on the position in the parent accessible + // (e.g. numerator for the first child of a mathmlfraction). + let mathRole = Utils.getMathRole(aAccessible); + if (mathRole) { + aOutput[this.outputOrder === OUTPUT_DESC_FIRST ? 'push' : 'unshift'] + ({string: this._getOutputName(mathRole)}); + } + if (roleStr) { + aOutput[this.outputOrder === OUTPUT_DESC_FIRST ? 'push' : 'unshift'] + ({string: this._getOutputName(roleStr)}); + } + }, + + /** + * Adds MathML menclose notations to the output. + * @param {Array} aOutput Output array. + * @param {nsIAccessible} aAccessible current accessible object. + */ + _addMencloseNotations: function _addMencloseNotations(aOutput, aAccessible) { + let notations = Utils.getAttributes(aAccessible).notation || 'longdiv'; + aOutput[this.outputOrder === OUTPUT_DESC_FIRST ? 'push' : 'unshift'].apply( + aOutput, notations.split(' ').map(notation => { + return { string: this._getOutputName('notation-' + notation) }; + })); + }, + + /** + * Adds an entry type attribute to the description if available. + * @param {Array} aOutput Output array. + * @param {nsIAccessible} aAccessible current accessible object. + * @param {String} aRoleStr aAccessible's role string. + */ + _addType: function _addType(aOutput, aAccessible, aRoleStr) { + if (aRoleStr !== 'entry') { + return; + } + + let typeName = Utils.getAttributes(aAccessible)['text-input-type']; + // Ignore the the input type="text" case. + if (!typeName || typeName === 'text') { + return; + } + aOutput.push({string: 'textInputType_' + typeName}); + }, + + _addState: function _addState(aOutput, aState, aRoleStr) {}, // jshint ignore:line + + _addRole: function _addRole(aOutput, aAccessible, aRoleStr) {}, // jshint ignore:line + + get outputOrder() { + if (!this._utteranceOrder) { + this._utteranceOrder = new PrefCache('accessibility.accessfu.utterance'); + } + return typeof this._utteranceOrder.value === 'number' ? + this._utteranceOrder.value : this.defaultOutputOrder; + }, + + _getOutputName: function _getOutputName(aName) { + return aName.replace(/\s/g, ''); + }, + + roleRuleMap: { + 'menubar': INCLUDE_DESC, + 'scrollbar': INCLUDE_DESC, + 'grip': INCLUDE_DESC, + 'alert': INCLUDE_DESC | INCLUDE_NAME, + 'menupopup': INCLUDE_DESC, + 'menuitem': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'tooltip': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'columnheader': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'rowheader': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'column': NAME_FROM_SUBTREE_RULE, + 'row': NAME_FROM_SUBTREE_RULE, + 'cell': INCLUDE_DESC | INCLUDE_NAME, + 'application': INCLUDE_NAME, + 'document': INCLUDE_NAME, + 'grouping': INCLUDE_DESC | INCLUDE_NAME, + 'toolbar': INCLUDE_DESC, + 'table': INCLUDE_DESC | INCLUDE_NAME, + 'link': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'helpballoon': NAME_FROM_SUBTREE_RULE, + 'list': INCLUDE_DESC | INCLUDE_NAME, + 'listitem': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'outline': INCLUDE_DESC, + 'outlineitem': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'pagetab': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'graphic': INCLUDE_DESC, + 'switch': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'pushbutton': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'checkbutton': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'radiobutton': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'buttondropdown': NAME_FROM_SUBTREE_RULE, + 'combobox': INCLUDE_DESC | INCLUDE_VALUE, + 'droplist': INCLUDE_DESC, + 'progressbar': INCLUDE_DESC | INCLUDE_VALUE, + 'slider': INCLUDE_DESC | INCLUDE_VALUE, + 'spinbutton': INCLUDE_DESC | INCLUDE_VALUE, + 'diagram': INCLUDE_DESC, + 'animation': INCLUDE_DESC, + 'equation': INCLUDE_DESC, + 'buttonmenu': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'buttondropdowngrid': NAME_FROM_SUBTREE_RULE, + 'pagetablist': INCLUDE_DESC, + 'canvas': INCLUDE_DESC, + 'check menu item': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'label': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'password text': INCLUDE_DESC, + 'popup menu': INCLUDE_DESC, + 'radio menu item': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'table column header': NAME_FROM_SUBTREE_RULE, + 'table row header': NAME_FROM_SUBTREE_RULE, + 'tear off menu item': NAME_FROM_SUBTREE_RULE, + 'toggle button': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'parent menuitem': NAME_FROM_SUBTREE_RULE, + 'header': INCLUDE_DESC, + 'footer': INCLUDE_DESC, + 'entry': INCLUDE_DESC | INCLUDE_NAME | INCLUDE_VALUE, + 'caption': INCLUDE_DESC, + 'document frame': INCLUDE_DESC, + 'heading': INCLUDE_DESC, + 'calendar': INCLUDE_DESC | INCLUDE_NAME, + 'combobox option': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'listbox option': INCLUDE_DESC | NAME_FROM_SUBTREE_RULE, + 'listbox rich option': NAME_FROM_SUBTREE_RULE, + 'gridcell': NAME_FROM_SUBTREE_RULE, + 'check rich option': NAME_FROM_SUBTREE_RULE, + 'term': NAME_FROM_SUBTREE_RULE, + 'definition': NAME_FROM_SUBTREE_RULE, + 'key': NAME_FROM_SUBTREE_RULE, + 'image map': INCLUDE_DESC, + 'option': INCLUDE_DESC, + 'listbox': INCLUDE_DESC, + 'definitionlist': INCLUDE_DESC | INCLUDE_NAME, + 'dialog': INCLUDE_DESC | INCLUDE_NAME, + 'chrome window': IGNORE_EXPLICIT_NAME, + 'app root': IGNORE_EXPLICIT_NAME, + 'statusbar': NAME_FROM_SUBTREE_RULE, + 'mathml table': INCLUDE_DESC | INCLUDE_NAME, + 'mathml labeled row': NAME_FROM_SUBTREE_RULE, + 'mathml table row': NAME_FROM_SUBTREE_RULE, + 'mathml cell': INCLUDE_DESC | INCLUDE_NAME, + 'mathml fraction': INCLUDE_DESC, + 'mathml square root': INCLUDE_DESC, + 'mathml root': INCLUDE_DESC, + 'mathml enclosed': INCLUDE_DESC, + 'mathml sub': INCLUDE_DESC, + 'mathml sup': INCLUDE_DESC, + 'mathml sub sup': INCLUDE_DESC, + 'mathml under': INCLUDE_DESC, + 'mathml over': INCLUDE_DESC, + 'mathml under over': INCLUDE_DESC, + 'mathml multiscripts': INCLUDE_DESC, + 'mathml identifier': INCLUDE_DESC, + 'mathml number': INCLUDE_DESC, + 'mathml operator': INCLUDE_DESC, + 'mathml text': INCLUDE_DESC, + 'mathml string literal': INCLUDE_DESC, + 'mathml row': INCLUDE_DESC, + 'mathml style': INCLUDE_DESC, + 'mathml error': INCLUDE_DESC }, + + mathmlRolesSet: new Set([ + Roles.MATHML_MATH, + Roles.MATHML_IDENTIFIER, + Roles.MATHML_NUMBER, + Roles.MATHML_OPERATOR, + Roles.MATHML_TEXT, + Roles.MATHML_STRING_LITERAL, + Roles.MATHML_GLYPH, + Roles.MATHML_ROW, + Roles.MATHML_FRACTION, + Roles.MATHML_SQUARE_ROOT, + Roles.MATHML_ROOT, + Roles.MATHML_FENCED, + Roles.MATHML_ENCLOSED, + Roles.MATHML_STYLE, + Roles.MATHML_SUB, + Roles.MATHML_SUP, + Roles.MATHML_SUB_SUP, + Roles.MATHML_UNDER, + Roles.MATHML_OVER, + Roles.MATHML_UNDER_OVER, + Roles.MATHML_MULTISCRIPTS, + Roles.MATHML_TABLE, + Roles.LABELED_ROW, + Roles.MATHML_TABLE_ROW, + Roles.MATHML_CELL, + Roles.MATHML_ACTION, + Roles.MATHML_ERROR, + Roles.MATHML_STACK, + Roles.MATHML_LONG_DIVISION, + Roles.MATHML_STACK_GROUP, + Roles.MATHML_STACK_ROW, + Roles.MATHML_STACK_CARRIES, + Roles.MATHML_STACK_CARRY, + Roles.MATHML_STACK_LINE + ]), + + objectOutputFunctions: { + _generateBaseOutput: + function _generateBaseOutput(aAccessible, aRoleStr, aState, aFlags) { + let output = []; + + if (aFlags & INCLUDE_DESC) { + this._addState(output, aState, aRoleStr); + this._addType(output, aAccessible, aRoleStr); + this._addRole(output, aAccessible, aRoleStr); + } + + if (aFlags & INCLUDE_VALUE && aAccessible.value.trim()) { + output[this.outputOrder === OUTPUT_DESC_FIRST ? 'push' : 'unshift']( + aAccessible.value); + } + + this._addName(output, aAccessible, aFlags); + this._addLandmark(output, aAccessible); + + return output; + }, + + label: function label(aAccessible, aRoleStr, aState, aFlags, aContext) { + if (aContext.isNestedControl || + aContext.accessible == Utils.getEmbeddedControl(aAccessible)) { + // If we are on a nested control, or a nesting label, + // we don't need the context. + return []; + } + + return this.objectOutputFunctions.defaultFunc.apply(this, arguments); + }, + + entry: function entry(aAccessible, aRoleStr, aState, aFlags) { + let rolestr = aState.contains(States.MULTI_LINE) ? 'textarea' : 'entry'; + return this.objectOutputFunctions.defaultFunc.apply( + this, [aAccessible, rolestr, aState, aFlags]); + }, + + pagetab: function pagetab(aAccessible, aRoleStr, aState, aFlags) { + let itemno = {}; + let itemof = {}; + aAccessible.groupPosition({}, itemof, itemno); + let output = []; + this._addState(output, aState); + this._addRole(output, aAccessible, aRoleStr); + output.push({ + string: 'objItemOfN', + args: [itemno.value, itemof.value] + }); + + this._addName(output, aAccessible, aFlags); + this._addLandmark(output, aAccessible); + + return output; + }, + + table: function table(aAccessible, aRoleStr, aState, aFlags) { + let output = []; + let table; + try { + table = aAccessible.QueryInterface(Ci.nsIAccessibleTable); + } catch (x) { + Logger.logException(x); + return output; + } finally { + // Check if it's a layout table, and bail out if true. + // We don't want to speak any table information for layout tables. + if (table.isProbablyForLayout()) { + return output; + } + this._addRole(output, aAccessible, aRoleStr); + output.push.call(output, { + string: this._getOutputName('tblColumnInfo'), + count: table.columnCount + }, { + string: this._getOutputName('tblRowInfo'), + count: table.rowCount + }); + this._addName(output, aAccessible, aFlags); + this._addLandmark(output, aAccessible); + return output; + } + }, + + gridcell: function gridcell(aAccessible, aRoleStr, aState, aFlags) { + let output = []; + this._addState(output, aState); + this._addName(output, aAccessible, aFlags); + this._addLandmark(output, aAccessible); + return output; + }, + + // Use the table output functions for MathML tabular elements. + mathmltable: function mathmltable() { + return this.objectOutputFunctions.table.apply(this, arguments); + }, + + mathmlcell: function mathmlcell() { + return this.objectOutputFunctions.cell.apply(this, arguments); + }, + + mathmlenclosed: function mathmlenclosed(aAccessible, aRoleStr, aState, + aFlags, aContext) { + let output = this.objectOutputFunctions.defaultFunc. + apply(this, [aAccessible, aRoleStr, aState, aFlags, aContext]); + this._addMencloseNotations(output, aAccessible); + return output; + } + } +}; + +/** + * Generates speech utterances from objects, actions and state changes. + * An utterance is an array of speech data. + * + * It should not be assumed that flattening an utterance array would create a + * gramatically correct sentence. For example, {@link genForObject} might + * return: ['graphic', 'Welcome to my home page']. + * Each string element in an utterance should be gramatically correct in itself. + * Another example from {@link genForObject}: ['list item 2 of 5', 'Alabama']. + * + * An utterance is ordered from the least to the most important. Speaking the + * last string usually makes sense, but speaking the first often won't. + * For example {@link genForAction} might return ['button', 'clicked'] for a + * clicked event. Speaking only 'clicked' makes sense. Speaking 'button' does + * not. + */ +this.UtteranceGenerator = { // jshint ignore:line + __proto__: OutputGenerator, // jshint ignore:line + + gActionMap: { + jump: 'jumpAction', + press: 'pressAction', + check: 'checkAction', + uncheck: 'uncheckAction', + on: 'onAction', + off: 'offAction', + select: 'selectAction', + unselect: 'unselectAction', + open: 'openAction', + close: 'closeAction', + switch: 'switchAction', + click: 'clickAction', + collapse: 'collapseAction', + expand: 'expandAction', + activate: 'activateAction', + cycle: 'cycleAction' + }, + + //TODO: May become more verbose in the future. + genForAction: function genForAction(aObject, aActionName) { + return [{string: this.gActionMap[aActionName]}]; + }, + + genForLiveRegion: + function genForLiveRegion(aContext, aIsHide, aModifiedText) { + let utterance = []; + if (aIsHide) { + utterance.push({string: 'hidden'}); + } + return utterance.concat(aModifiedText || this.genForContext(aContext)); + }, + + genForAnnouncement: function genForAnnouncement(aAnnouncement) { + return [{ + string: aAnnouncement + }]; + }, + + genForTabStateChange: function genForTabStateChange(aObject, aTabState) { + switch (aTabState) { + case 'newtab': + return [{string: 'tabNew'}]; + case 'loading': + return [{string: 'tabLoading'}]; + case 'loaded': + return [aObject.name, {string: 'tabLoaded'}]; + case 'loadstopped': + return [{string: 'tabLoadStopped'}]; + case 'reload': + return [{string: 'tabReload'}]; + default: + return []; + } + }, + + genForEditingMode: function genForEditingMode(aIsEditing) { + return [{string: aIsEditing ? 'editingMode' : 'navigationMode'}]; + }, + + objectOutputFunctions: { + + __proto__: OutputGenerator.objectOutputFunctions, // jshint ignore:line + + defaultFunc: function defaultFunc() { + return this.objectOutputFunctions._generateBaseOutput.apply( + this, arguments); + }, + + heading: function heading(aAccessible, aRoleStr, aState, aFlags) { + let level = {}; + aAccessible.groupPosition(level, {}, {}); + let utterance = [{string: 'headingLevel', args: [level.value]}]; + + this._addName(utterance, aAccessible, aFlags); + this._addLandmark(utterance, aAccessible); + + return utterance; + }, + + listitem: function listitem(aAccessible, aRoleStr, aState, aFlags) { + let itemno = {}; + let itemof = {}; + aAccessible.groupPosition({}, itemof, itemno); + let utterance = []; + if (itemno.value == 1) { + // Start of list + utterance.push({string: 'listStart'}); + } + else if (itemno.value == itemof.value) { + // last item + utterance.push({string: 'listEnd'}); + } + + this._addName(utterance, aAccessible, aFlags); + this._addLandmark(utterance, aAccessible); + + return utterance; + }, + + list: function list(aAccessible, aRoleStr, aState, aFlags) { + return this._getListUtterance + (aAccessible, aRoleStr, aFlags, aAccessible.childCount); + }, + + definitionlist: + function definitionlist(aAccessible, aRoleStr, aState, aFlags) { + return this._getListUtterance + (aAccessible, aRoleStr, aFlags, aAccessible.childCount / 2); + }, + + application: function application(aAccessible, aRoleStr, aState, aFlags) { + // Don't utter location of applications, it gets tiring. + if (aAccessible.name != aAccessible.DOMNode.location) { + return this.objectOutputFunctions.defaultFunc.apply(this, + [aAccessible, aRoleStr, aState, aFlags]); + } + + return []; + }, + + cell: function cell(aAccessible, aRoleStr, aState, aFlags, aContext) { + let utterance = []; + let cell = aContext.getCellInfo(aAccessible); + if (cell) { + let addCellChanged = + function addCellChanged(aUtterance, aChanged, aString, aIndex) { + if (aChanged) { + aUtterance.push({string: aString, args: [aIndex + 1]}); + } + }; + let addExtent = function addExtent(aUtterance, aExtent, aString) { + if (aExtent > 1) { + aUtterance.push({string: aString, args: [aExtent]}); + } + }; + let addHeaders = function addHeaders(aUtterance, aHeaders) { + if (aHeaders.length > 0) { + aUtterance.push.apply(aUtterance, aHeaders); + } + }; + + addCellChanged(utterance, cell.columnChanged, 'columnInfo', + cell.columnIndex); + addCellChanged(utterance, cell.rowChanged, 'rowInfo', cell.rowIndex); + + addExtent(utterance, cell.columnExtent, 'spansColumns'); + addExtent(utterance, cell.rowExtent, 'spansRows'); + + addHeaders(utterance, cell.columnHeaders); + addHeaders(utterance, cell.rowHeaders); + } + + this._addName(utterance, aAccessible, aFlags); + this._addLandmark(utterance, aAccessible); + + return utterance; + }, + + columnheader: function columnheader() { + return this.objectOutputFunctions.cell.apply(this, arguments); + }, + + rowheader: function rowheader() { + return this.objectOutputFunctions.cell.apply(this, arguments); + }, + + statictext: function statictext(aAccessible) { + if (Utils.isListItemDecorator(aAccessible, true)) { + return []; + } + + return this.objectOutputFunctions.defaultFunc.apply(this, arguments); + } + }, + + _getContextStart: function _getContextStart(aContext) { + return aContext.newAncestry; + }, + + _addRole: function _addRole(aOutput, aAccessible, aRoleStr) { + if (this.mathmlRolesSet.has(aAccessible.role)) { + this._addMathRoles(aOutput, aAccessible, aRoleStr); + } else { + aOutput.push({string: this._getOutputName(aRoleStr)}); + } + }, + + _addState: function _addState(aOutput, aState, aRoleStr) { + + if (aState.contains(States.UNAVAILABLE)) { + aOutput.push({string: 'stateUnavailable'}); + } + + if (aState.contains(States.READONLY)) { + aOutput.push({string: 'stateReadonly'}); + } + + // Don't utter this in Jelly Bean, we let TalkBack do it for us there. + // This is because we expose the checked information on the node itself. + // XXX: this means the checked state is always appended to the end, + // regardless of the utterance ordering preference. + if ((Utils.AndroidSdkVersion < 16 || Utils.MozBuildApp === 'browser') && + aState.contains(States.CHECKABLE)) { + let checked = aState.contains(States.CHECKED); + let statetr; + if (aRoleStr === 'switch') { + statetr = checked ? 'stateOn' : 'stateOff'; + } else { + statetr = checked ? 'stateChecked' : 'stateNotChecked'; + } + aOutput.push({string: statetr}); + } + + if (aState.contains(States.PRESSED)) { + aOutput.push({string: 'statePressed'}); + } + + if (aState.contains(States.EXPANDABLE)) { + let statetr = aState.contains(States.EXPANDED) ? + 'stateExpanded' : 'stateCollapsed'; + aOutput.push({string: statetr}); + } + + if (aState.contains(States.REQUIRED)) { + aOutput.push({string: 'stateRequired'}); + } + + if (aState.contains(States.TRAVERSED)) { + aOutput.push({string: 'stateTraversed'}); + } + + if (aState.contains(States.HASPOPUP)) { + aOutput.push({string: 'stateHasPopup'}); + } + + if (aState.contains(States.SELECTED)) { + aOutput.push({string: 'stateSelected'}); + } + }, + + _getListUtterance: + function _getListUtterance(aAccessible, aRoleStr, aFlags, aItemCount) { + let utterance = []; + this._addRole(utterance, aAccessible, aRoleStr); + utterance.push({ + string: this._getOutputName('listItemsCount'), + count: aItemCount + }); + + this._addName(utterance, aAccessible, aFlags); + this._addLandmark(utterance, aAccessible); + + return utterance; + } +}; + +this.BrailleGenerator = { // jshint ignore:line + __proto__: OutputGenerator, // jshint ignore:line + + genForContext: function genForContext(aContext) { + let output = OutputGenerator.genForContext.apply(this, arguments); + + let acc = aContext.accessible; + + // add the static text indicating a list item; do this for both listitems or + // direct first children of listitems, because these are both common + // browsing scenarios + let addListitemIndicator = function addListitemIndicator(indicator = '*') { + output.unshift(indicator); + }; + + if (acc.indexInParent === 1 && + acc.parent.role == Roles.LISTITEM && + acc.previousSibling.role == Roles.STATICTEXT) { + if (acc.parent.parent && acc.parent.parent.DOMNode && + acc.parent.parent.DOMNode.nodeName == 'UL') { + addListitemIndicator(); + } else { + addListitemIndicator(acc.previousSibling.name.trim()); + } + } else if (acc.role == Roles.LISTITEM && acc.firstChild && + acc.firstChild.role == Roles.STATICTEXT) { + if (acc.parent.DOMNode.nodeName == 'UL') { + addListitemIndicator(); + } else { + addListitemIndicator(acc.firstChild.name.trim()); + } + } + + return output; + }, + + objectOutputFunctions: { + + __proto__: OutputGenerator.objectOutputFunctions, // jshint ignore:line + + defaultFunc: function defaultFunc() { + return this.objectOutputFunctions._generateBaseOutput.apply( + this, arguments); + }, + + listitem: function listitem(aAccessible, aRoleStr, aState, aFlags) { + let braille = []; + + this._addName(braille, aAccessible, aFlags); + this._addLandmark(braille, aAccessible); + + return braille; + }, + + cell: function cell(aAccessible, aRoleStr, aState, aFlags, aContext) { + let braille = []; + let cell = aContext.getCellInfo(aAccessible); + if (cell) { + let addHeaders = function addHeaders(aBraille, aHeaders) { + if (aHeaders.length > 0) { + aBraille.push.apply(aBraille, aHeaders); + } + }; + + braille.push({ + string: this._getOutputName('cellInfo'), + args: [cell.columnIndex + 1, cell.rowIndex + 1] + }); + + addHeaders(braille, cell.columnHeaders); + addHeaders(braille, cell.rowHeaders); + } + + this._addName(braille, aAccessible, aFlags); + this._addLandmark(braille, aAccessible); + return braille; + }, + + columnheader: function columnheader() { + return this.objectOutputFunctions.cell.apply(this, arguments); + }, + + rowheader: function rowheader() { + return this.objectOutputFunctions.cell.apply(this, arguments); + }, + + statictext: function statictext(aAccessible) { + // Since we customize the list bullet's output, we add the static + // text from the first node in each listitem, so skip it here. + if (Utils.isListItemDecorator(aAccessible)) { + return []; + } + + return this.objectOutputFunctions._useStateNotRole.apply(this, arguments); + }, + + _useStateNotRole: + function _useStateNotRole(aAccessible, aRoleStr, aState, aFlags) { + let braille = []; + this._addState(braille, aState, aRoleStr); + this._addName(braille, aAccessible, aFlags); + this._addLandmark(braille, aAccessible); + + return braille; + }, + + switch: function braille_generator_object_output_functions_switch() { + return this.objectOutputFunctions._useStateNotRole.apply(this, arguments); + }, + + checkbutton: function checkbutton() { + return this.objectOutputFunctions._useStateNotRole.apply(this, arguments); + }, + + radiobutton: function radiobutton() { + return this.objectOutputFunctions._useStateNotRole.apply(this, arguments); + }, + + togglebutton: function togglebutton() { + return this.objectOutputFunctions._useStateNotRole.apply(this, arguments); + } + }, + + _getContextStart: function _getContextStart(aContext) { + if (aContext.accessible.parent.role == Roles.LINK) { + return [aContext.accessible.parent]; + } + + return []; + }, + + _getOutputName: function _getOutputName(aName) { + return OutputGenerator._getOutputName(aName) + 'Abbr'; + }, + + _addRole: function _addRole(aBraille, aAccessible, aRoleStr) { + if (this.mathmlRolesSet.has(aAccessible.role)) { + this._addMathRoles(aBraille, aAccessible, aRoleStr); + } else { + aBraille.push({string: this._getOutputName(aRoleStr)}); + } + }, + + _addState: function _addState(aBraille, aState, aRoleStr) { + if (aState.contains(States.CHECKABLE)) { + aBraille.push({ + string: aState.contains(States.CHECKED) ? + this._getOutputName('stateChecked') : + this._getOutputName('stateUnchecked') + }); + } + if (aRoleStr === 'toggle button') { + aBraille.push({ + string: aState.contains(States.PRESSED) ? + this._getOutputName('statePressed') : + this._getOutputName('stateUnpressed') + }); + } + } +}; diff --git a/accessible/jsat/PointerAdapter.jsm b/accessible/jsat/PointerAdapter.jsm new file mode 100644 index 000000000..ff54976b7 --- /dev/null +++ b/accessible/jsat/PointerAdapter.jsm @@ -0,0 +1,174 @@ +/* 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/. */ + +/* global Components, XPCOMUtils, Utils, Logger, GestureSettings, + GestureTracker */ +/* exported PointerRelay, PointerAdapter */ + +'use strict'; + +const Ci = Components.interfaces; +const Cu = Components.utils; + +this.EXPORTED_SYMBOLS = ['PointerRelay', 'PointerAdapter']; // jshint ignore:line + +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); + +XPCOMUtils.defineLazyModuleGetter(this, 'Utils', // jshint ignore:line + 'resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Logger', // jshint ignore:line + 'resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'GestureSettings', // jshint ignore:line + 'resource://gre/modules/accessibility/Gestures.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'GestureTracker', // jshint ignore:line + 'resource://gre/modules/accessibility/Gestures.jsm'); + +// The virtual touch ID generated by a mouse event. +const MOUSE_ID = 'mouse'; +// Synthesized touch ID. +const SYNTH_ID = -1; + +var PointerRelay = { // jshint ignore:line + /** + * A mapping of events we should be intercepting. Entries with a value of + * |true| are used for compiling high-level gesture events. Entries with a + * value of |false| are cancelled and do not propogate to content. + */ + get _eventsOfInterest() { + delete this._eventsOfInterest; + + switch (Utils.widgetToolkit) { + case 'android': + this._eventsOfInterest = { + 'touchstart' : true, + 'touchmove' : true, + 'touchend' : true }; + break; + + case 'gonk': + this._eventsOfInterest = { + 'touchstart' : true, + 'touchmove' : true, + 'touchend' : true, + 'mousedown' : false, + 'mousemove' : false, + 'mouseup': false, + 'click': false }; + break; + + default: + // Desktop. + this._eventsOfInterest = { + 'mousemove' : true, + 'mousedown' : true, + 'mouseup': true, + 'click': false + }; + if ('ontouchstart' in Utils.win) { + for (let eventType of ['touchstart', 'touchmove', 'touchend']) { + this._eventsOfInterest[eventType] = true; + } + } + break; + } + + return this._eventsOfInterest; + }, + + _eventMap: { + 'touchstart' : 'pointerdown', + 'mousedown' : 'pointerdown', + 'touchmove' : 'pointermove', + 'mousemove' : 'pointermove', + 'touchend' : 'pointerup', + 'mouseup': 'pointerup' + }, + + start: function PointerRelay_start(aOnPointerEvent) { + Logger.debug('PointerRelay.start'); + this.onPointerEvent = aOnPointerEvent; + for (let eventType in this._eventsOfInterest) { + Utils.win.addEventListener(eventType, this, true, true); + } + }, + + stop: function PointerRelay_stop() { + Logger.debug('PointerRelay.stop'); + delete this.lastPointerMove; + delete this.onPointerEvent; + for (let eventType in this._eventsOfInterest) { + Utils.win.removeEventListener(eventType, this, true, true); + } + }, + + handleEvent: function PointerRelay_handleEvent(aEvent) { + // Don't bother with chrome mouse events. + if (Utils.MozBuildApp === 'browser' && + aEvent.view.top instanceof Ci.nsIDOMChromeWindow) { + return; + } + if (aEvent.mozInputSource === Ci.nsIDOMMouseEvent.MOZ_SOURCE_UNKNOWN || + aEvent.isSynthesized) { + // Ignore events that are scripted or clicks from the a11y API. + return; + } + + let changedTouches = aEvent.changedTouches || [{ + identifier: MOUSE_ID, + screenX: aEvent.screenX, + screenY: aEvent.screenY, + target: aEvent.target + }]; + + if (Utils.widgetToolkit === 'android' && + changedTouches.length === 1 && changedTouches[0].identifier === 1) { + return; + } + + if (changedTouches.length === 1 && + changedTouches[0].identifier === SYNTH_ID) { + return; + } + + aEvent.preventDefault(); + aEvent.stopImmediatePropagation(); + + let type = aEvent.type; + if (!this._eventsOfInterest[type]) { + return; + } + let pointerType = this._eventMap[type]; + this.onPointerEvent({ + type: pointerType, + points: Array.prototype.map.call(changedTouches, + function mapTouch(aTouch) { + return { + identifier: aTouch.identifier, + x: aTouch.screenX, + y: aTouch.screenY + }; + } + ) + }); + } +}; + +this.PointerAdapter = { // jshint ignore:line + start: function PointerAdapter_start() { + Logger.debug('PointerAdapter.start'); + GestureTracker.reset(); + PointerRelay.start(this.handleEvent); + }, + + stop: function PointerAdapter_stop() { + Logger.debug('PointerAdapter.stop'); + PointerRelay.stop(); + GestureTracker.reset(); + }, + + handleEvent: function PointerAdapter_handleEvent(aDetail) { + let timeStamp = Date.now(); + GestureTracker.handle(aDetail, timeStamp); + } +}; diff --git a/accessible/jsat/Presentation.jsm b/accessible/jsat/Presentation.jsm new file mode 100644 index 000000000..6912d0ea5 --- /dev/null +++ b/accessible/jsat/Presentation.jsm @@ -0,0 +1,769 @@ +/* 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/. */ + +/* global Components, XPCOMUtils, Utils, Logger, BraillePresenter, Presentation, + UtteranceGenerator, BrailleGenerator, States, Roles, PivotContext */ +/* exported Presentation */ + +'use strict'; + +const {utils: Cu, interfaces: Ci} = Components; + +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); +Cu.import('resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Logger', // jshint ignore:line + 'resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'PivotContext', // jshint ignore:line + 'resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'UtteranceGenerator', // jshint ignore:line + 'resource://gre/modules/accessibility/OutputGenerator.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'BrailleGenerator', // jshint ignore:line + 'resource://gre/modules/accessibility/OutputGenerator.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Roles', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'States', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); + +this.EXPORTED_SYMBOLS = ['Presentation']; // jshint ignore:line + +/** + * The interface for all presenter classes. A presenter could be, for example, + * a speech output module, or a visual cursor indicator. + */ +function Presenter() {} + +Presenter.prototype = { + /** + * The type of presenter. Used for matching it with the appropriate output method. + */ + type: 'Base', + + /** + * The virtual cursor's position changed. + * @param {PivotContext} aContext the context object for the new pivot + * position. + * @param {int} aReason the reason for the pivot change. + * See nsIAccessiblePivot. + * @param {bool} aIsFromUserInput the pivot change was invoked by the user + */ + pivotChanged: function pivotChanged(aContext, aReason, aIsFromUserInput) {}, // jshint ignore:line + + /** + * An object's action has been invoked. + * @param {nsIAccessible} aObject the object that has been invoked. + * @param {string} aActionName the name of the action. + */ + actionInvoked: function actionInvoked(aObject, aActionName) {}, // jshint ignore:line + + /** + * Text has changed, either by the user or by the system. TODO. + */ + textChanged: function textChanged(aAccessible, aIsInserted, aStartOffset, // jshint ignore:line + aLength, aText, aModifiedText) {}, // jshint ignore:line + + /** + * Text selection has changed. TODO. + */ + textSelectionChanged: function textSelectionChanged( + aText, aStart, aEnd, aOldStart, aOldEnd, aIsFromUserInput) {}, // jshint ignore:line + + /** + * Selection has changed. TODO. + * @param {nsIAccessible} aObject the object that has been selected. + */ + selectionChanged: function selectionChanged(aObject) {}, // jshint ignore:line + + /** + * Name has changed. + * @param {nsIAccessible} aAccessible the object whose value has changed. + */ + nameChanged: function nameChanged(aAccessible) {}, // jshint ignore: line + + /** + * Value has changed. + * @param {nsIAccessible} aAccessible the object whose value has changed. + */ + valueChanged: function valueChanged(aAccessible) {}, // jshint ignore:line + + /** + * The tab, or the tab's document state has changed. + * @param {nsIAccessible} aDocObj the tab document accessible that has had its + * state changed, or null if the tab has no associated document yet. + * @param {string} aPageState the state name for the tab, valid states are: + * 'newtab', 'loading', 'newdoc', 'loaded', 'stopped', and 'reload'. + */ + tabStateChanged: function tabStateChanged(aDocObj, aPageState) {}, // jshint ignore:line + + /** + * The current tab has changed. + * @param {PivotContext} aDocContext context object for tab's + * document. + * @param {PivotContext} aVCContext context object for tab's current + * virtual cursor position. + */ + tabSelected: function tabSelected(aDocContext, aVCContext) {}, // jshint ignore:line + + /** + * The viewport has changed, either a scroll, pan, zoom, or + * landscape/portrait toggle. + * @param {Window} aWindow window of viewport that changed. + * @param {PivotContext} aCurrentContext context of last pivot change. + */ + viewportChanged: function viewportChanged(aWindow, aCurrentContext) {}, // jshint ignore:line + + /** + * We have entered or left text editing mode. + */ + editingModeChanged: function editingModeChanged(aIsEditing) {}, // jshint ignore:line + + /** + * Announce something. Typically an app state change. + */ + announce: function announce(aAnnouncement) {}, // jshint ignore:line + + + /** + * User tried to move cursor forward or backward with no success. + * @param {string} aMoveMethod move method that was used (eg. 'moveNext'). + */ + noMove: function noMove(aMoveMethod) {}, + + /** + * Announce a live region. + * @param {PivotContext} aContext context object for an accessible. + * @param {boolean} aIsPolite A politeness level for a live region. + * @param {boolean} aIsHide An indicator of hide/remove event. + * @param {string} aModifiedText Optional modified text. + */ + liveRegion: function liveRegionShown(aContext, aIsPolite, aIsHide, // jshint ignore:line + aModifiedText) {} // jshint ignore:line +}; + +/** + * Visual presenter. Draws a box around the virtual cursor's position. + */ +function VisualPresenter() {} + +VisualPresenter.prototype = Object.create(Presenter.prototype); + +VisualPresenter.prototype.type = 'Visual'; + +/** + * The padding in pixels between the object and the highlight border. + */ +VisualPresenter.prototype.BORDER_PADDING = 2; + +VisualPresenter.prototype.viewportChanged = + function VisualPresenter_viewportChanged(aWindow, aCurrentContext) { + if (!aCurrentContext) { + return null; + } + + let currentAcc = aCurrentContext.accessibleForBounds; + let start = aCurrentContext.startOffset; + let end = aCurrentContext.endOffset; + if (Utils.isAliveAndVisible(currentAcc)) { + let bounds = (start === -1 && end === -1) ? Utils.getBounds(currentAcc) : + Utils.getTextBounds(currentAcc, start, end); + + return { + type: this.type, + details: { + eventType: 'viewport-change', + bounds: bounds, + padding: this.BORDER_PADDING + } + }; + } + + return null; + }; + +VisualPresenter.prototype.pivotChanged = + function VisualPresenter_pivotChanged(aContext) { + if (!aContext.accessible) { + // XXX: Don't hide because another vc may be using the highlight. + return null; + } + + try { + aContext.accessibleForBounds.scrollTo( + Ci.nsIAccessibleScrollType.SCROLL_TYPE_ANYWHERE); + + let bounds = (aContext.startOffset === -1 && aContext.endOffset === -1) ? + aContext.bounds : Utils.getTextBounds(aContext.accessibleForBounds, + aContext.startOffset, + aContext.endOffset); + + return { + type: this.type, + details: { + eventType: 'vc-change', + bounds: bounds, + padding: this.BORDER_PADDING + } + }; + } catch (e) { + Logger.logException(e, 'Failed to get bounds'); + return null; + } + }; + +VisualPresenter.prototype.tabSelected = + function VisualPresenter_tabSelected(aDocContext, aVCContext) { + return this.pivotChanged(aVCContext, Ci.nsIAccessiblePivot.REASON_NONE); + }; + +VisualPresenter.prototype.tabStateChanged = + function VisualPresenter_tabStateChanged(aDocObj, aPageState) { + if (aPageState == 'newdoc') { + return {type: this.type, details: {eventType: 'tabstate-change'}}; + } + + return null; + }; + +/** + * Android presenter. Fires Android a11y events. + */ +function AndroidPresenter() {} + +AndroidPresenter.prototype = Object.create(Presenter.prototype); + +AndroidPresenter.prototype.type = 'Android'; + +// Android AccessibilityEvent type constants. +AndroidPresenter.prototype.ANDROID_VIEW_CLICKED = 0x01; +AndroidPresenter.prototype.ANDROID_VIEW_LONG_CLICKED = 0x02; +AndroidPresenter.prototype.ANDROID_VIEW_SELECTED = 0x04; +AndroidPresenter.prototype.ANDROID_VIEW_FOCUSED = 0x08; +AndroidPresenter.prototype.ANDROID_VIEW_TEXT_CHANGED = 0x10; +AndroidPresenter.prototype.ANDROID_WINDOW_STATE_CHANGED = 0x20; +AndroidPresenter.prototype.ANDROID_VIEW_HOVER_ENTER = 0x80; +AndroidPresenter.prototype.ANDROID_VIEW_HOVER_EXIT = 0x100; +AndroidPresenter.prototype.ANDROID_VIEW_SCROLLED = 0x1000; +AndroidPresenter.prototype.ANDROID_VIEW_TEXT_SELECTION_CHANGED = 0x2000; +AndroidPresenter.prototype.ANDROID_ANNOUNCEMENT = 0x4000; +AndroidPresenter.prototype.ANDROID_VIEW_ACCESSIBILITY_FOCUSED = 0x8000; +AndroidPresenter.prototype.ANDROID_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY = + 0x20000; + +AndroidPresenter.prototype.pivotChanged = + function AndroidPresenter_pivotChanged(aContext, aReason) { + if (!aContext.accessible) { + return null; + } + + let androidEvents = []; + + let isExploreByTouch = (aReason == Ci.nsIAccessiblePivot.REASON_POINT && + Utils.AndroidSdkVersion >= 14); + let focusEventType = (Utils.AndroidSdkVersion >= 16) ? + this.ANDROID_VIEW_ACCESSIBILITY_FOCUSED : + this.ANDROID_VIEW_FOCUSED; + + if (isExploreByTouch) { + // This isn't really used by TalkBack so this is a half-hearted attempt + // for now. + androidEvents.push({eventType: this.ANDROID_VIEW_HOVER_EXIT, text: []}); + } + + let brailleOutput = {}; + if (Utils.AndroidSdkVersion >= 16) { + if (!this._braillePresenter) { + this._braillePresenter = new BraillePresenter(); + } + brailleOutput = this._braillePresenter.pivotChanged(aContext, aReason). + details; + } + + if (aReason === Ci.nsIAccessiblePivot.REASON_TEXT) { + if (Utils.AndroidSdkVersion >= 16) { + let adjustedText = aContext.textAndAdjustedOffsets; + + androidEvents.push({ + eventType: this.ANDROID_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY, + text: [adjustedText.text], + fromIndex: adjustedText.startOffset, + toIndex: adjustedText.endOffset + }); + } + } else { + let state = Utils.getState(aContext.accessible); + androidEvents.push({eventType: (isExploreByTouch) ? + this.ANDROID_VIEW_HOVER_ENTER : focusEventType, + text: Utils.localize(UtteranceGenerator.genForContext( + aContext)), + bounds: aContext.bounds, + clickable: aContext.accessible.actionCount > 0, + checkable: state.contains(States.CHECKABLE), + checked: state.contains(States.CHECKED), + brailleOutput: brailleOutput}); + } + + + return { + type: this.type, + details: androidEvents + }; + }; + +AndroidPresenter.prototype.actionInvoked = + function AndroidPresenter_actionInvoked(aObject, aActionName) { + let state = Utils.getState(aObject); + + // Checkable objects use TalkBack's text derived from the event state, + // so we don't populate the text here. + let text = ''; + if (!state.contains(States.CHECKABLE)) { + text = Utils.localize(UtteranceGenerator.genForAction(aObject, + aActionName)); + } + + return { + type: this.type, + details: [{ + eventType: this.ANDROID_VIEW_CLICKED, + text: text, + checked: state.contains(States.CHECKED) + }] + }; + }; + +AndroidPresenter.prototype.tabSelected = + function AndroidPresenter_tabSelected(aDocContext, aVCContext) { + // Send a pivot change message with the full context utterance for this doc. + return this.pivotChanged(aVCContext, Ci.nsIAccessiblePivot.REASON_NONE); + }; + +AndroidPresenter.prototype.tabStateChanged = + function AndroidPresenter_tabStateChanged(aDocObj, aPageState) { + return this.announce( + UtteranceGenerator.genForTabStateChange(aDocObj, aPageState)); + }; + +AndroidPresenter.prototype.textChanged = function AndroidPresenter_textChanged( + aAccessible, aIsInserted, aStart, aLength, aText, aModifiedText) { + let eventDetails = { + eventType: this.ANDROID_VIEW_TEXT_CHANGED, + text: [aText], + fromIndex: aStart, + removedCount: 0, + addedCount: 0 + }; + + if (aIsInserted) { + eventDetails.addedCount = aLength; + eventDetails.beforeText = + aText.substring(0, aStart) + aText.substring(aStart + aLength); + } else { + eventDetails.removedCount = aLength; + eventDetails.beforeText = + aText.substring(0, aStart) + aModifiedText + aText.substring(aStart); + } + + return {type: this.type, details: [eventDetails]}; + }; + +AndroidPresenter.prototype.textSelectionChanged = + function AndroidPresenter_textSelectionChanged(aText, aStart, aEnd, aOldStart, + aOldEnd, aIsFromUserInput) { + let androidEvents = []; + + if (Utils.AndroidSdkVersion >= 14 && !aIsFromUserInput) { + if (!this._braillePresenter) { + this._braillePresenter = new BraillePresenter(); + } + let brailleOutput = this._braillePresenter.textSelectionChanged( + aText, aStart, aEnd, aOldStart, aOldEnd, aIsFromUserInput).details; + + androidEvents.push({ + eventType: this.ANDROID_VIEW_TEXT_SELECTION_CHANGED, + text: [aText], + fromIndex: aStart, + toIndex: aEnd, + itemCount: aText.length, + brailleOutput: brailleOutput + }); + } + + if (Utils.AndroidSdkVersion >= 16 && aIsFromUserInput) { + let [from, to] = aOldStart < aStart ? + [aOldStart, aStart] : [aStart, aOldStart]; + androidEvents.push({ + eventType: this.ANDROID_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY, + text: [aText], + fromIndex: from, + toIndex: to + }); + } + + return { + type: this.type, + details: androidEvents + }; + }; + +AndroidPresenter.prototype.viewportChanged = + function AndroidPresenter_viewportChanged(aWindow, aCurrentContext) { + if (Utils.AndroidSdkVersion < 14) { + return null; + } + + let events = [{ + eventType: this.ANDROID_VIEW_SCROLLED, + text: [], + scrollX: aWindow.scrollX, + scrollY: aWindow.scrollY, + maxScrollX: aWindow.scrollMaxX, + maxScrollY: aWindow.scrollMaxY + }]; + + if (Utils.AndroidSdkVersion >= 16 && aCurrentContext) { + let currentAcc = aCurrentContext.accessibleForBounds; + if (Utils.isAliveAndVisible(currentAcc)) { + events.push({ + eventType: this.ANDROID_VIEW_ACCESSIBILITY_FOCUSED, + bounds: Utils.getBounds(currentAcc) + }); + } + } + + return { + type: this.type, + details: events + }; + }; + +AndroidPresenter.prototype.editingModeChanged = + function AndroidPresenter_editingModeChanged(aIsEditing) { + return this.announce(UtteranceGenerator.genForEditingMode(aIsEditing)); + }; + +AndroidPresenter.prototype.announce = + function AndroidPresenter_announce(aAnnouncement) { + let localizedAnnouncement = Utils.localize(aAnnouncement).join(' '); + return { + type: this.type, + details: [{ + eventType: (Utils.AndroidSdkVersion >= 16) ? + this.ANDROID_ANNOUNCEMENT : this.ANDROID_VIEW_TEXT_CHANGED, + text: [localizedAnnouncement], + addedCount: localizedAnnouncement.length, + removedCount: 0, + fromIndex: 0 + }] + }; + }; + +AndroidPresenter.prototype.liveRegion = + function AndroidPresenter_liveRegion(aContext, aIsPolite, + aIsHide, aModifiedText) { + return this.announce( + UtteranceGenerator.genForLiveRegion(aContext, aIsHide, aModifiedText)); + }; + +AndroidPresenter.prototype.noMove = + function AndroidPresenter_noMove(aMoveMethod) { + return { + type: this.type, + details: [ + { eventType: this.ANDROID_VIEW_ACCESSIBILITY_FOCUSED, + exitView: aMoveMethod, + text: [''] + }] + }; + }; + +/** + * A B2G presenter for Gaia. + */ +function B2GPresenter() {} + +B2GPresenter.prototype = Object.create(Presenter.prototype); + +B2GPresenter.prototype.type = 'B2G'; + +B2GPresenter.prototype.keyboardEchoSetting = + new PrefCache('accessibility.accessfu.keyboard_echo'); +B2GPresenter.prototype.NO_ECHO = 0; +B2GPresenter.prototype.CHARACTER_ECHO = 1; +B2GPresenter.prototype.WORD_ECHO = 2; +B2GPresenter.prototype.CHARACTER_AND_WORD_ECHO = 3; + +/** + * A pattern used for haptic feedback. + * @type {Array} + */ +B2GPresenter.prototype.PIVOT_CHANGE_HAPTIC_PATTERN = [40]; + +/** + * Pivot move reasons. + * @type {Array} + */ +B2GPresenter.prototype.pivotChangedReasons = ['none', 'next', 'prev', 'first', + 'last', 'text', 'point']; + +B2GPresenter.prototype.pivotChanged = + function B2GPresenter_pivotChanged(aContext, aReason, aIsUserInput) { + if (!aContext.accessible) { + return null; + } + + return { + type: this.type, + details: { + eventType: 'vc-change', + data: UtteranceGenerator.genForContext(aContext), + options: { + pattern: this.PIVOT_CHANGE_HAPTIC_PATTERN, + isKey: Utils.isActivatableOnFingerUp(aContext.accessible), + reason: this.pivotChangedReasons[aReason], + isUserInput: aIsUserInput, + hints: aContext.interactionHints + } + } + }; + }; + +B2GPresenter.prototype.nameChanged = + function B2GPresenter_nameChanged(aAccessible, aIsPolite = true) { + return { + type: this.type, + details: { + eventType: 'name-change', + data: aAccessible.name, + options: {enqueue: aIsPolite} + } + }; + }; + +B2GPresenter.prototype.valueChanged = + function B2GPresenter_valueChanged(aAccessible, aIsPolite = true) { + + // the editable value changes are handled in the text changed presenter + if (Utils.getState(aAccessible).contains(States.EDITABLE)) { + return null; + } + + return { + type: this.type, + details: { + eventType: 'value-change', + data: aAccessible.value, + options: {enqueue: aIsPolite} + } + }; + }; + +B2GPresenter.prototype.textChanged = function B2GPresenter_textChanged( + aAccessible, aIsInserted, aStart, aLength, aText, aModifiedText) { + let echoSetting = this.keyboardEchoSetting.value; + let text = ''; + + if (echoSetting == this.CHARACTER_ECHO || + echoSetting == this.CHARACTER_AND_WORD_ECHO) { + text = aModifiedText; + } + + // add word if word boundary is added + if ((echoSetting == this.WORD_ECHO || + echoSetting == this.CHARACTER_AND_WORD_ECHO) && + aIsInserted && aLength === 1) { + let accText = aAccessible.QueryInterface(Ci.nsIAccessibleText); + let startBefore = {}, endBefore = {}; + let startAfter = {}, endAfter = {}; + accText.getTextBeforeOffset(aStart, + Ci.nsIAccessibleText.BOUNDARY_WORD_END, startBefore, endBefore); + let maybeWord = accText.getTextBeforeOffset(aStart + 1, + Ci.nsIAccessibleText.BOUNDARY_WORD_END, startAfter, endAfter); + if (endBefore.value !== endAfter.value) { + text += maybeWord; + } + } + + return { + type: this.type, + details: { + eventType: 'text-change', + data: text + } + }; + + }; + +B2GPresenter.prototype.actionInvoked = + function B2GPresenter_actionInvoked(aObject, aActionName) { + return { + type: this.type, + details: { + eventType: 'action', + data: UtteranceGenerator.genForAction(aObject, aActionName) + } + }; + }; + +B2GPresenter.prototype.liveRegion = function B2GPresenter_liveRegion(aContext, + aIsPolite, aIsHide, aModifiedText) { + return { + type: this.type, + details: { + eventType: 'liveregion-change', + data: UtteranceGenerator.genForLiveRegion(aContext, aIsHide, + aModifiedText), + options: {enqueue: aIsPolite} + } + }; + }; + +B2GPresenter.prototype.announce = + function B2GPresenter_announce(aAnnouncement) { + return { + type: this.type, + details: { + eventType: 'announcement', + data: aAnnouncement + } + }; + }; + +B2GPresenter.prototype.noMove = + function B2GPresenter_noMove(aMoveMethod) { + return { + type: this.type, + details: { + eventType: 'no-move', + data: aMoveMethod + } + }; + }; + +/** + * A braille presenter + */ +function BraillePresenter() {} + +BraillePresenter.prototype = Object.create(Presenter.prototype); + +BraillePresenter.prototype.type = 'Braille'; + +BraillePresenter.prototype.pivotChanged = + function BraillePresenter_pivotChanged(aContext) { + if (!aContext.accessible) { + return null; + } + + return { + type: this.type, + details: { + output: Utils.localize(BrailleGenerator.genForContext(aContext)).join( + ' '), + selectionStart: 0, + selectionEnd: 0 + } + }; + }; + +BraillePresenter.prototype.textSelectionChanged = + function BraillePresenter_textSelectionChanged(aText, aStart, aEnd) { + return { + type: this.type, + details: { + selectionStart: aStart, + selectionEnd: aEnd + } + }; + }; + +this.Presentation = { // jshint ignore:line + get presenters() { + delete this.presenters; + let presenterMap = { + 'mobile/android': [VisualPresenter, AndroidPresenter], + 'b2g': [VisualPresenter, B2GPresenter], + 'browser': [VisualPresenter, B2GPresenter, AndroidPresenter] + }; + this.presenters = presenterMap[Utils.MozBuildApp].map(P => new P()); + return this.presenters; + }, + + get displayedAccessibles() { + delete this.displayedAccessibles; + this.displayedAccessibles = new WeakMap(); + return this.displayedAccessibles; + }, + + pivotChanged: function Presentation_pivotChanged( + aPosition, aOldPosition, aReason, aStartOffset, aEndOffset, aIsUserInput) { + let context = new PivotContext( + aPosition, aOldPosition, aStartOffset, aEndOffset); + if (context.accessible) { + this.displayedAccessibles.set(context.accessible.document.window, context); + } + + return this.presenters.map(p => p.pivotChanged(context, aReason, aIsUserInput)); + }, + + actionInvoked: function Presentation_actionInvoked(aObject, aActionName) { + return this.presenters.map(p => p.actionInvoked(aObject, aActionName)); + }, + + textChanged: function Presentation_textChanged(aAccessible, aIsInserted, + aStartOffset, aLength, aText, + aModifiedText) { + return this.presenters.map(p => p.textChanged(aAccessible, aIsInserted, + aStartOffset, aLength, + aText, aModifiedText)); + }, + + textSelectionChanged: function textSelectionChanged(aText, aStart, aEnd, + aOldStart, aOldEnd, + aIsFromUserInput) { + return this.presenters.map(p => p.textSelectionChanged(aText, aStart, aEnd, + aOldStart, aOldEnd, + aIsFromUserInput)); + }, + + nameChanged: function nameChanged(aAccessible) { + return this.presenters.map(p => p.nameChanged(aAccessible)); + }, + + valueChanged: function valueChanged(aAccessible) { + return this.presenters.map(p => p.valueChanged(aAccessible)); + }, + + tabStateChanged: function Presentation_tabStateChanged(aDocObj, aPageState) { + return this.presenters.map(p => p.tabStateChanged(aDocObj, aPageState)); + }, + + viewportChanged: function Presentation_viewportChanged(aWindow) { + let context = this.displayedAccessibles.get(aWindow); + return this.presenters.map(p => p.viewportChanged(aWindow, context)); + }, + + editingModeChanged: function Presentation_editingModeChanged(aIsEditing) { + return this.presenters.map(p => p.editingModeChanged(aIsEditing)); + }, + + announce: function Presentation_announce(aAnnouncement) { + // XXX: Typically each presenter uses the UtteranceGenerator, + // but there really isn't a point here. + return this.presenters.map(p => p.announce(UtteranceGenerator.genForAnnouncement(aAnnouncement))); + }, + + noMove: function Presentation_noMove(aMoveMethod) { + return this.presenters.map(p => p.noMove(aMoveMethod)); + }, + + liveRegion: function Presentation_liveRegion(aAccessible, aIsPolite, aIsHide, + aModifiedText) { + let context; + if (!aModifiedText) { + context = new PivotContext(aAccessible, null, -1, -1, true, + aIsHide ? true : false); + } + return this.presenters.map(p => p.liveRegion(context, aIsPolite, aIsHide, + aModifiedText)); + } +}; diff --git a/accessible/jsat/Traversal.jsm b/accessible/jsat/Traversal.jsm new file mode 100644 index 000000000..5b3bbdf89 --- /dev/null +++ b/accessible/jsat/Traversal.jsm @@ -0,0 +1,419 @@ +/* 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/. */ + +/* global PrefCache, Roles, Prefilters, States, Filters, Utils, + TraversalRules, Components, XPCOMUtils */ +/* exported TraversalRules, TraversalHelper */ + +'use strict'; + +const Ci = Components.interfaces; +const Cu = Components.utils; + +this.EXPORTED_SYMBOLS = ['TraversalRules', 'TraversalHelper']; // jshint ignore:line + +Cu.import('resource://gre/modules/accessibility/Utils.jsm'); +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Roles', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Filters', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'States', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Prefilters', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); + +var gSkipEmptyImages = new PrefCache('accessibility.accessfu.skip_empty_images'); + +function BaseTraversalRule(aRoles, aMatchFunc, aPreFilter, aContainerRule) { + this._explicitMatchRoles = new Set(aRoles); + this._matchRoles = aRoles; + if (aRoles.length) { + if (aRoles.indexOf(Roles.LABEL) < 0) { + this._matchRoles.push(Roles.LABEL); + } + if (aRoles.indexOf(Roles.INTERNAL_FRAME) < 0) { + // Used for traversing in to child OOP frames. + this._matchRoles.push(Roles.INTERNAL_FRAME); + } + } + this._matchFunc = aMatchFunc || function() { return Filters.MATCH; }; + this.preFilter = aPreFilter || gSimplePreFilter; + this.containerRule = aContainerRule; +} + +BaseTraversalRule.prototype = { + getMatchRoles: function BaseTraversalRule_getmatchRoles(aRoles) { + aRoles.value = this._matchRoles; + return aRoles.value.length; + }, + + match: function BaseTraversalRule_match(aAccessible) + { + let role = aAccessible.role; + if (role == Roles.INTERNAL_FRAME) { + return (Utils.getMessageManager(aAccessible.DOMNode)) ? + Filters.MATCH | Filters.IGNORE_SUBTREE : Filters.IGNORE; + } + + let matchResult = + (this._explicitMatchRoles.has(role) || !this._explicitMatchRoles.size) ? + this._matchFunc(aAccessible) : Filters.IGNORE; + + // If we are on a label that nests a checkbox/radio we should land on it. + // It is a bigger touch target, and it reduces clutter. + if (role == Roles.LABEL && !(matchResult & Filters.IGNORE_SUBTREE)) { + let control = Utils.getEmbeddedControl(aAccessible); + if (control && this._explicitMatchRoles.has(control.role)) { + matchResult = this._matchFunc(control) | Filters.IGNORE_SUBTREE; + } + } + + return matchResult; + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIAccessibleTraversalRule]) +}; + +var gSimpleTraversalRoles = + [Roles.MENUITEM, + Roles.LINK, + Roles.PAGETAB, + Roles.GRAPHIC, + Roles.STATICTEXT, + Roles.TEXT_LEAF, + Roles.PUSHBUTTON, + Roles.CHECKBUTTON, + Roles.RADIOBUTTON, + Roles.COMBOBOX, + Roles.PROGRESSBAR, + Roles.BUTTONDROPDOWN, + Roles.BUTTONMENU, + Roles.CHECK_MENU_ITEM, + Roles.PASSWORD_TEXT, + Roles.RADIO_MENU_ITEM, + Roles.TOGGLE_BUTTON, + Roles.ENTRY, + Roles.KEY, + Roles.HEADER, + Roles.HEADING, + Roles.SLIDER, + Roles.SPINBUTTON, + Roles.OPTION, + Roles.LISTITEM, + Roles.GRID_CELL, + Roles.COLUMNHEADER, + Roles.ROWHEADER, + Roles.STATUSBAR, + Roles.SWITCH, + Roles.MATHML_MATH]; + +var gSimpleMatchFunc = function gSimpleMatchFunc(aAccessible) { + // An object is simple, if it either has a single child lineage, + // or has a flat subtree. + function isSingleLineage(acc) { + for (let child = acc; child; child = child.firstChild) { + if (Utils.visibleChildCount(child) > 1) { + return false; + } + } + return true; + } + + function isFlatSubtree(acc) { + for (let child = acc.firstChild; child; child = child.nextSibling) { + // text leafs inherit the actionCount of any ancestor that has a click + // listener. + if ([Roles.TEXT_LEAF, Roles.STATICTEXT].indexOf(child.role) >= 0) { + continue; + } + if (Utils.visibleChildCount(child) > 0 || child.actionCount > 0) { + return false; + } + } + return true; + } + + switch (aAccessible.role) { + case Roles.COMBOBOX: + // We don't want to ignore the subtree because this is often + // where the list box hangs out. + return Filters.MATCH; + case Roles.TEXT_LEAF: + { + // Nameless text leaves are boring, skip them. + let name = aAccessible.name; + return (name && name.trim()) ? Filters.MATCH : Filters.IGNORE; + } + case Roles.STATICTEXT: + // Ignore prefix static text in list items. They are typically bullets or numbers. + return Utils.isListItemDecorator(aAccessible) ? + Filters.IGNORE : Filters.MATCH; + case Roles.GRAPHIC: + return TraversalRules._shouldSkipImage(aAccessible); + case Roles.HEADER: + case Roles.HEADING: + case Roles.COLUMNHEADER: + case Roles.ROWHEADER: + case Roles.STATUSBAR: + if ((aAccessible.childCount > 0 || aAccessible.name) && + (isSingleLineage(aAccessible) || isFlatSubtree(aAccessible))) { + return Filters.MATCH | Filters.IGNORE_SUBTREE; + } + return Filters.IGNORE; + case Roles.GRID_CELL: + return isSingleLineage(aAccessible) || isFlatSubtree(aAccessible) ? + Filters.MATCH | Filters.IGNORE_SUBTREE : Filters.IGNORE; + case Roles.LISTITEM: + { + let item = aAccessible.childCount === 2 && + aAccessible.firstChild.role === Roles.STATICTEXT ? + aAccessible.lastChild : aAccessible; + return isSingleLineage(item) || isFlatSubtree(item) ? + Filters.MATCH | Filters.IGNORE_SUBTREE : Filters.IGNORE; + } + default: + // Ignore the subtree, if there is one. So that we don't land on + // the same content that was already presented by its parent. + return Filters.MATCH | + Filters.IGNORE_SUBTREE; + } +}; + +var gSimplePreFilter = Prefilters.DEFUNCT | + Prefilters.INVISIBLE | + Prefilters.ARIA_HIDDEN | + Prefilters.TRANSPARENT; + +this.TraversalRules = { // jshint ignore:line + Simple: new BaseTraversalRule(gSimpleTraversalRoles, gSimpleMatchFunc), + + SimpleOnScreen: new BaseTraversalRule( + gSimpleTraversalRoles, gSimpleMatchFunc, + Prefilters.DEFUNCT | Prefilters.INVISIBLE | Prefilters.ARIA_HIDDEN | + Prefilters.TRANSPARENT | Prefilters.OFFSCREEN), + + Anchor: new BaseTraversalRule( + [Roles.LINK], + function Anchor_match(aAccessible) + { + // We want to ignore links, only focus named anchors. + if (Utils.getState(aAccessible).contains(States.LINKED)) { + return Filters.IGNORE; + } else { + return Filters.MATCH; + } + }), + + Button: new BaseTraversalRule( + [Roles.PUSHBUTTON, + Roles.SPINBUTTON, + Roles.TOGGLE_BUTTON, + Roles.BUTTONDROPDOWN, + Roles.BUTTONDROPDOWNGRID]), + + Combobox: new BaseTraversalRule( + [Roles.COMBOBOX, + Roles.LISTBOX]), + + Landmark: new BaseTraversalRule( + [], + function Landmark_match(aAccessible) { + return Utils.getLandmarkName(aAccessible) ? Filters.MATCH : + Filters.IGNORE; + }, null, true), + + /* A rule for Android's section navigation, lands on landmarks, regions, and + on headings to aid navigation of traditionally structured documents */ + Section: new BaseTraversalRule( + [], + function Section_match(aAccessible) { + if (aAccessible.role === Roles.HEADING) { + return Filters.MATCH; + } + + let matchedRole = Utils.matchRoles(aAccessible, [ + 'banner', + 'complementary', + 'contentinfo', + 'main', + 'navigation', + 'search', + 'region' + ]); + + return matchedRole ? Filters.MATCH : Filters.IGNORE; + }, null, true), + + Entry: new BaseTraversalRule( + [Roles.ENTRY, + Roles.PASSWORD_TEXT]), + + FormElement: new BaseTraversalRule( + [Roles.PUSHBUTTON, + Roles.SPINBUTTON, + Roles.TOGGLE_BUTTON, + Roles.BUTTONDROPDOWN, + Roles.BUTTONDROPDOWNGRID, + Roles.COMBOBOX, + Roles.LISTBOX, + Roles.ENTRY, + Roles.PASSWORD_TEXT, + Roles.PAGETAB, + Roles.RADIOBUTTON, + Roles.RADIO_MENU_ITEM, + Roles.SLIDER, + Roles.CHECKBUTTON, + Roles.CHECK_MENU_ITEM, + Roles.SWITCH]), + + Graphic: new BaseTraversalRule( + [Roles.GRAPHIC], + function Graphic_match(aAccessible) { + return TraversalRules._shouldSkipImage(aAccessible); + }), + + Heading: new BaseTraversalRule( + [Roles.HEADING], + function Heading_match(aAccessible) { + return aAccessible.childCount > 0 ? Filters.MATCH : Filters.IGNORE; + }), + + ListItem: new BaseTraversalRule( + [Roles.LISTITEM, + Roles.TERM]), + + Link: new BaseTraversalRule( + [Roles.LINK], + function Link_match(aAccessible) + { + // We want to ignore anchors, only focus real links. + if (Utils.getState(aAccessible).contains(States.LINKED)) { + return Filters.MATCH; + } else { + return Filters.IGNORE; + } + }), + + /* For TalkBack's "Control" granularity. Form conrols and links */ + Control: new BaseTraversalRule( + [Roles.PUSHBUTTON, + Roles.SPINBUTTON, + Roles.TOGGLE_BUTTON, + Roles.BUTTONDROPDOWN, + Roles.BUTTONDROPDOWNGRID, + Roles.COMBOBOX, + Roles.LISTBOX, + Roles.ENTRY, + Roles.PASSWORD_TEXT, + Roles.PAGETAB, + Roles.RADIOBUTTON, + Roles.RADIO_MENU_ITEM, + Roles.SLIDER, + Roles.CHECKBUTTON, + Roles.CHECK_MENU_ITEM, + Roles.SWITCH, + Roles.LINK, + Roles.MENUITEM], + function Control_match(aAccessible) + { + // We want to ignore anchors, only focus real links. + if (aAccessible.role == Roles.LINK && + !Utils.getState(aAccessible).contains(States.LINKED)) { + return Filters.IGNORE; + } + return Filters.MATCH; + }), + + List: new BaseTraversalRule( + [Roles.LIST, + Roles.DEFINITION_LIST], + null, null, true), + + PageTab: new BaseTraversalRule( + [Roles.PAGETAB]), + + Paragraph: new BaseTraversalRule( + [Roles.PARAGRAPH, + Roles.SECTION], + function Paragraph_match(aAccessible) { + for (let child = aAccessible.firstChild; child; child = child.nextSibling) { + if (child.role === Roles.TEXT_LEAF) { + return Filters.MATCH | Filters.IGNORE_SUBTREE; + } + } + + return Filters.IGNORE; + }), + + RadioButton: new BaseTraversalRule( + [Roles.RADIOBUTTON, + Roles.RADIO_MENU_ITEM]), + + Separator: new BaseTraversalRule( + [Roles.SEPARATOR]), + + Table: new BaseTraversalRule( + [Roles.TABLE]), + + Checkbox: new BaseTraversalRule( + [Roles.CHECKBUTTON, + Roles.CHECK_MENU_ITEM, + Roles.SWITCH /* A type of checkbox that represents on/off values */]), + + _shouldSkipImage: function _shouldSkipImage(aAccessible) { + if (gSkipEmptyImages.value && aAccessible.name === '') { + return Filters.IGNORE; + } + return Filters.MATCH; + } +}; + +this.TraversalHelper = { + _helperPivotCache: null, + + get helperPivotCache() { + delete this.helperPivotCache; + this.helperPivotCache = new WeakMap(); + return this.helperPivotCache; + }, + + getHelperPivot: function TraversalHelper_getHelperPivot(aRoot) { + let pivot = this.helperPivotCache.get(aRoot.DOMNode); + if (!pivot) { + pivot = Utils.AccService.createAccessiblePivot(aRoot); + this.helperPivotCache.set(aRoot.DOMNode, pivot); + } + + return pivot; + }, + + move: function TraversalHelper_move(aVirtualCursor, aMethod, aRule) { + let rule = TraversalRules[aRule]; + + if (rule.containerRule) { + let moved = false; + let helperPivot = this.getHelperPivot(aVirtualCursor.root); + helperPivot.position = aVirtualCursor.position; + + // We continue to step through containers until there is one with an + // atomic child (via 'Simple') on which we could land. + while (!moved) { + if (helperPivot[aMethod](rule)) { + aVirtualCursor.modalRoot = helperPivot.position; + moved = aVirtualCursor.moveFirst(TraversalRules.Simple); + aVirtualCursor.modalRoot = null; + } else { + // If we failed to step to another container, break and return false. + break; + } + } + + return moved; + } else { + return aVirtualCursor[aMethod](rule); + } + } + +}; diff --git a/accessible/jsat/Utils.jsm b/accessible/jsat/Utils.jsm new file mode 100644 index 000000000..4e478cab0 --- /dev/null +++ b/accessible/jsat/Utils.jsm @@ -0,0 +1,1114 @@ +/* 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/. */ + +/* global Components, XPCOMUtils, Services, PluralForm, Logger, Rect, Utils, + States, Relations, Roles, dump, Events, PivotContext, PrefCache */ +/* exported Utils, Logger, PivotContext, PrefCache, SettingCache */ + +'use strict'; + +const {classes: Cc, utils: Cu, interfaces: Ci} = Components; + +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Services', // jshint ignore:line + 'resource://gre/modules/Services.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Rect', // jshint ignore:line + 'resource://gre/modules/Geometry.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Roles', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Events', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Relations', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'States', // jshint ignore:line + 'resource://gre/modules/accessibility/Constants.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'PluralForm', // jshint ignore:line + 'resource://gre/modules/PluralForm.jsm'); + +this.EXPORTED_SYMBOLS = ['Utils', 'Logger', 'PivotContext', 'PrefCache', // jshint ignore:line + 'SettingCache']; + +this.Utils = { // jshint ignore:line + _buildAppMap: { + '{3c2e2abc-06d4-11e1-ac3b-374f68613e61}': 'b2g', + '{d1bfe7d9-c01e-4237-998b-7b5f960a4314}': 'graphene', + '{ec8030f7-c20a-464f-9b0e-13a3a9e97384}': 'browser', + '{aa3c5121-dab2-40e2-81ca-7ea25febc110}': 'mobile/android', + '{a23983c0-fd0e-11dc-95ff-0800200c9a66}': 'mobile/xul' + }, + + init: function Utils_init(aWindow) { + if (this._win) { + // XXX: only supports attaching to one window now. + throw new Error('Only one top-level window could used with AccessFu'); + } + this._win = Cu.getWeakReference(aWindow); + }, + + uninit: function Utils_uninit() { + if (!this._win) { + return; + } + delete this._win; + }, + + get win() { + if (!this._win) { + return null; + } + return this._win.get(); + }, + + get winUtils() { + let win = this.win; + if (!win) { + return null; + } + return win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface( + Ci.nsIDOMWindowUtils); + }, + + get AccService() { + if (!this._AccService) { + this._AccService = Cc['@mozilla.org/accessibilityService;1']. + getService(Ci.nsIAccessibilityService); + } + + return this._AccService; + }, + + set MozBuildApp(value) { + this._buildApp = value; + }, + + get MozBuildApp() { + if (!this._buildApp) { + this._buildApp = this._buildAppMap[Services.appinfo.ID]; + } + return this._buildApp; + }, + + get OS() { + if (!this._OS) { + this._OS = Services.appinfo.OS; + } + return this._OS; + }, + + get widgetToolkit() { + if (!this._widgetToolkit) { + this._widgetToolkit = Services.appinfo.widgetToolkit; + } + return this._widgetToolkit; + }, + + get ScriptName() { + if (!this._ScriptName) { + this._ScriptName = + (Services.appinfo.processType == 2) ? 'AccessFuContent' : 'AccessFu'; + } + return this._ScriptName; + }, + + get AndroidSdkVersion() { + if (!this._AndroidSdkVersion) { + if (Services.appinfo.OS == 'Android') { + this._AndroidSdkVersion = Services.sysinfo.getPropertyAsInt32( + 'version'); + } else { + // Most useful in desktop debugging. + this._AndroidSdkVersion = 16; + } + } + return this._AndroidSdkVersion; + }, + + set AndroidSdkVersion(value) { + // When we want to mimic another version. + this._AndroidSdkVersion = value; + }, + + get BrowserApp() { + if (!this.win) { + return null; + } + switch (this.MozBuildApp) { + case 'mobile/android': + return this.win.BrowserApp; + case 'browser': + return this.win.gBrowser; + case 'b2g': + return this.win.shell; + default: + return null; + } + }, + + get CurrentBrowser() { + if (!this.BrowserApp) { + return null; + } + if (this.MozBuildApp == 'b2g') { + return this.BrowserApp.contentBrowser; + } + return this.BrowserApp.selectedBrowser; + }, + + get CurrentContentDoc() { + let browser = this.CurrentBrowser; + return browser ? browser.contentDocument : null; + }, + + get AllMessageManagers() { + let messageManagers = new Set(); + + function collectLeafMessageManagers(mm) { + for (let i = 0; i < mm.childCount; i++) { + let childMM = mm.getChildAt(i); + + if ('sendAsyncMessage' in childMM) { + messageManagers.add(childMM); + } else { + collectLeafMessageManagers(childMM); + } + } + } + + collectLeafMessageManagers(this.win.messageManager); + + let document = this.CurrentContentDoc; + + if (document) { + if (document.location.host === 'b2g') { + // The document is a b2g app chrome (ie. Mulet). + let contentBrowser = this.win.content.shell.contentBrowser; + messageManagers.add(this.getMessageManager(contentBrowser)); + document = contentBrowser.contentDocument; + } + + let remoteframes = document.querySelectorAll('iframe'); + + for (let i = 0; i < remoteframes.length; ++i) { + let mm = this.getMessageManager(remoteframes[i]); + if (mm) { + messageManagers.add(mm); + } + } + + } + + return messageManagers; + }, + + get isContentProcess() { + delete this.isContentProcess; + this.isContentProcess = + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; + return this.isContentProcess; + }, + + localize: function localize(aOutput) { + let outputArray = Array.isArray(aOutput) ? aOutput : [aOutput]; + let localized = + outputArray.map(details => this.stringBundle.get(details)); + // Clean up the white space. + return localized.filter(word => word).map(word => word.trim()). + filter(trimmed => trimmed); + }, + + get stringBundle() { + delete this.stringBundle; + let bundle = Services.strings.createBundle( + 'chrome://global/locale/AccessFu.properties'); + this.stringBundle = { + get: function stringBundle_get(aDetails = {}) { + if (!aDetails || typeof aDetails === 'string') { + return aDetails; + } + let str = ''; + let string = aDetails.string; + if (!string) { + return str; + } + try { + let args = aDetails.args; + let count = aDetails.count; + if (args) { + str = bundle.formatStringFromName(string, args, args.length); + } else { + str = bundle.GetStringFromName(string); + } + if (count) { + str = PluralForm.get(count, str); + str = str.replace('#1', count); + } + } catch (e) { + Logger.debug('Failed to get a string from a bundle for', string); + } finally { + return str; + } + } + }; + return this.stringBundle; + }, + + getMessageManager: function getMessageManager(aBrowser) { + try { + return aBrowser.QueryInterface(Ci.nsIFrameLoaderOwner). + frameLoader.messageManager; + } catch (x) { + return null; + } + }, + + getState: function getState(aAccessibleOrEvent) { + if (aAccessibleOrEvent instanceof Ci.nsIAccessibleStateChangeEvent) { + return new State( + aAccessibleOrEvent.isExtraState ? 0 : aAccessibleOrEvent.state, + aAccessibleOrEvent.isExtraState ? aAccessibleOrEvent.state : 0); + } else { + let state = {}; + let extState = {}; + aAccessibleOrEvent.getState(state, extState); + return new State(state.value, extState.value); + } + }, + + getAttributes: function getAttributes(aAccessible) { + let attributes = {}; + + if (aAccessible && aAccessible.attributes) { + let attributesEnum = aAccessible.attributes.enumerate(); + + // Populate |attributes| object with |aAccessible|'s attribute key-value + // pairs. + while (attributesEnum.hasMoreElements()) { + let attribute = attributesEnum.getNext().QueryInterface( + Ci.nsIPropertyElement); + attributes[attribute.key] = attribute.value; + } + } + + return attributes; + }, + + getVirtualCursor: function getVirtualCursor(aDocument) { + let doc = (aDocument instanceof Ci.nsIAccessible) ? aDocument : + this.AccService.getAccessibleFor(aDocument); + + return doc.QueryInterface(Ci.nsIAccessibleDocument).virtualCursor; + }, + + getContentResolution: function _getContentResolution(aAccessible) { + let res = { value: 1 }; + aAccessible.document.window.QueryInterface( + Ci.nsIInterfaceRequestor).getInterface( + Ci.nsIDOMWindowUtils).getResolution(res); + return res.value; + }, + + getBounds: function getBounds(aAccessible, aPreserveContentScale) { + let objX = {}, objY = {}, objW = {}, objH = {}; + aAccessible.getBounds(objX, objY, objW, objH); + + let scale = aPreserveContentScale ? 1 : + this.getContentResolution(aAccessible); + + return new Rect(objX.value, objY.value, objW.value, objH.value).scale( + scale, scale); + }, + + getTextBounds: function getTextBounds(aAccessible, aStart, aEnd, + aPreserveContentScale) { + let accText = aAccessible.QueryInterface(Ci.nsIAccessibleText); + let objX = {}, objY = {}, objW = {}, objH = {}; + accText.getRangeExtents(aStart, aEnd, objX, objY, objW, objH, + Ci.nsIAccessibleCoordinateType.COORDTYPE_SCREEN_RELATIVE); + + let scale = aPreserveContentScale ? 1 : + this.getContentResolution(aAccessible); + + return new Rect(objX.value, objY.value, objW.value, objH.value).scale( + scale, scale); + }, + + /** + * Get current display DPI. + */ + get dpi() { + delete this.dpi; + this.dpi = this.winUtils.displayDPI; + return this.dpi; + }, + + isInSubtree: function isInSubtree(aAccessible, aSubTreeRoot) { + let acc = aAccessible; + + // If aSubTreeRoot is an accessible document, we will only walk up the + // ancestry of documents and skip everything else. + if (aSubTreeRoot instanceof Ci.nsIAccessibleDocument) { + while (acc) { + let parentDoc = acc instanceof Ci.nsIAccessibleDocument ? + acc.parentDocument : acc.document; + if (parentDoc === aSubTreeRoot) { + return true; + } + acc = parentDoc; + } + return false; + } + + while (acc) { + if (acc == aSubTreeRoot) { + return true; + } + + try { + acc = acc.parent; + } catch (x) { + Logger.debug('Failed to get parent:', x); + acc = null; + } + } + + return false; + }, + + isHidden: function isHidden(aAccessible) { + // Need to account for aria-hidden, so can't just check for INVISIBLE + // state. + let hidden = Utils.getAttributes(aAccessible).hidden; + return hidden && hidden === 'true'; + }, + + visibleChildCount: function visibleChildCount(aAccessible) { + let count = 0; + for (let child = aAccessible.firstChild; child; child = child.nextSibling) { + if (!this.isHidden(child)) { + ++count; + } + } + return count; + }, + + inHiddenSubtree: function inHiddenSubtree(aAccessible) { + for (let acc=aAccessible; acc; acc=acc.parent) { + if (this.isHidden(acc)) { + return true; + } + } + return false; + }, + + isAliveAndVisible: function isAliveAndVisible(aAccessible, aIsOnScreen) { + if (!aAccessible) { + return false; + } + + try { + let state = this.getState(aAccessible); + if (state.contains(States.DEFUNCT) || state.contains(States.INVISIBLE) || + (aIsOnScreen && state.contains(States.OFFSCREEN)) || + Utils.inHiddenSubtree(aAccessible)) { + return false; + } + } catch (x) { + return false; + } + + return true; + }, + + matchAttributeValue: function matchAttributeValue(aAttributeValue, values) { + let attrSet = new Set(aAttributeValue.split(' ')); + for (let value of values) { + if (attrSet.has(value)) { + return value; + } + } + }, + + getLandmarkName: function getLandmarkName(aAccessible) { + return this.matchRoles(aAccessible, [ + 'banner', + 'complementary', + 'contentinfo', + 'main', + 'navigation', + 'search' + ]); + }, + + getMathRole: function getMathRole(aAccessible) { + return this.matchRoles(aAccessible, [ + 'base', + 'close-fence', + 'denominator', + 'numerator', + 'open-fence', + 'overscript', + 'presubscript', + 'presuperscript', + 'root-index', + 'subscript', + 'superscript', + 'underscript' + ]); + }, + + matchRoles: function matchRoles(aAccessible, aRoles) { + let roles = this.getAttributes(aAccessible)['xml-roles']; + if (!roles) { + return; + } + + // Looking up a role that would match any in the provided roles. + return this.matchAttributeValue(roles, aRoles); + }, + + getEmbeddedControl: function getEmbeddedControl(aLabel) { + if (aLabel) { + let relation = aLabel.getRelationByType(Relations.LABEL_FOR); + for (let i = 0; i < relation.targetsCount; i++) { + let target = relation.getTarget(i); + if (target.parent === aLabel) { + return target; + } + } + } + + return null; + }, + + isListItemDecorator: function isListItemDecorator(aStaticText, + aExcludeOrdered) { + let parent = aStaticText.parent; + if (aExcludeOrdered && parent.parent.DOMNode.nodeName === 'OL') { + return false; + } + + return parent.role === Roles.LISTITEM && parent.childCount > 1 && + aStaticText.indexInParent === 0; + }, + + dispatchChromeEvent: function dispatchChromeEvent(aType, aDetails) { + let details = { + type: aType, + details: JSON.stringify( + typeof aDetails === 'string' ? { eventType : aDetails } : aDetails) + }; + let window = this.win; + let shell = window.shell || window.content.shell; + if (shell) { + // On B2G device. + shell.sendChromeEvent(details); + } else { + // Dispatch custom event to have support for desktop and screen reader + // emulator add-on. + window.dispatchEvent(new window.CustomEvent(aType, { + bubbles: true, + cancelable: true, + detail: details + })); + } + + }, + + isActivatableOnFingerUp: function isActivatableOnFingerUp(aAccessible) { + if (aAccessible.role === Roles.KEY) { + return true; + } + let quick_activate = this.getAttributes(aAccessible)['moz-quick-activate']; + return quick_activate && JSON.parse(quick_activate); + } +}; + +/** + * State object used internally to process accessible's states. + * @param {Number} aBase Base state. + * @param {Number} aExtended Extended state. + */ +function State(aBase, aExtended) { + this.base = aBase; + this.extended = aExtended; +} + +State.prototype = { + contains: function State_contains(other) { + return !!(this.base & other.base || this.extended & other.extended); + }, + toString: function State_toString() { + let stateStrings = Utils.AccService. + getStringStates(this.base, this.extended); + let statesArray = new Array(stateStrings.length); + for (let i = 0; i < statesArray.length; i++) { + statesArray[i] = stateStrings.item(i); + } + return '[' + statesArray.join(', ') + ']'; + } +}; + +this.Logger = { // jshint ignore:line + GESTURE: -1, + DEBUG: 0, + INFO: 1, + WARNING: 2, + ERROR: 3, + _LEVEL_NAMES: ['GESTURE', 'DEBUG', 'INFO', 'WARNING', 'ERROR'], + + logLevel: 1, // INFO; + + test: false, + + log: function log(aLogLevel) { + if (aLogLevel < this.logLevel) { + return; + } + + let args = Array.prototype.slice.call(arguments, 1); + let message = (typeof(args[0]) === 'function' ? args[0]() : args).join(' '); + message = '[' + Utils.ScriptName + '] ' + this._LEVEL_NAMES[aLogLevel + 1] + + ' ' + message + '\n'; + dump(message); + // Note: used for testing purposes. If |this.test| is true, also log to + // the console service. + if (this.test) { + try { + Services.console.logStringMessage(message); + } catch (ex) { + // There was an exception logging to the console service. + } + } + }, + + info: function info() { + this.log.apply( + this, [this.INFO].concat(Array.prototype.slice.call(arguments))); + }, + + gesture: function gesture() { + this.log.apply( + this, [this.GESTURE].concat(Array.prototype.slice.call(arguments))); + }, + + debug: function debug() { + this.log.apply( + this, [this.DEBUG].concat(Array.prototype.slice.call(arguments))); + }, + + warning: function warning() { + this.log.apply( + this, [this.WARNING].concat(Array.prototype.slice.call(arguments))); + }, + + error: function error() { + this.log.apply( + this, [this.ERROR].concat(Array.prototype.slice.call(arguments))); + }, + + logException: function logException( + aException, aErrorMessage = 'An exception has occured') { + try { + let stackMessage = ''; + if (aException.stack) { + stackMessage = ' ' + aException.stack.replace(/\n/g, '\n '); + } else if (aException.location) { + let frame = aException.location; + let stackLines = []; + while (frame && frame.lineNumber) { + stackLines.push( + ' ' + frame.name + '@' + frame.filename + ':' + frame.lineNumber); + frame = frame.caller; + } + stackMessage = stackLines.join('\n'); + } else { + stackMessage = + '(' + aException.fileName + ':' + aException.lineNumber + ')'; + } + this.error(aErrorMessage + ':\n ' + + aException.message + '\n' + + stackMessage); + } catch (x) { + this.error(x); + } + }, + + accessibleToString: function accessibleToString(aAccessible) { + if (!aAccessible) { + return '[ null ]'; + } + + try { + return'[ ' + Utils.AccService.getStringRole(aAccessible.role) + + ' | ' + aAccessible.name + ' ]'; + } catch (x) { + return '[ defunct ]'; + } + }, + + eventToString: function eventToString(aEvent) { + let str = Utils.AccService.getStringEventType(aEvent.eventType); + if (aEvent.eventType == Events.STATE_CHANGE) { + let event = aEvent.QueryInterface(Ci.nsIAccessibleStateChangeEvent); + let stateStrings = event.isExtraState ? + Utils.AccService.getStringStates(0, event.state) : + Utils.AccService.getStringStates(event.state, 0); + str += ' (' + stateStrings.item(0) + ')'; + } + + if (aEvent.eventType == Events.VIRTUALCURSOR_CHANGED) { + let event = aEvent.QueryInterface( + Ci.nsIAccessibleVirtualCursorChangeEvent); + let pivot = aEvent.accessible.QueryInterface( + Ci.nsIAccessibleDocument).virtualCursor; + str += ' (' + this.accessibleToString(event.oldAccessible) + ' -> ' + + this.accessibleToString(pivot.position) + ')'; + } + + return str; + }, + + statesToString: function statesToString(aAccessible) { + return Utils.getState(aAccessible).toString(); + }, + + dumpTree: function dumpTree(aLogLevel, aRootAccessible) { + if (aLogLevel < this.logLevel) { + return; + } + + this._dumpTreeInternal(aLogLevel, aRootAccessible, 0); + }, + + _dumpTreeInternal: + function _dumpTreeInternal(aLogLevel, aAccessible, aIndent) { + let indentStr = ''; + for (let i = 0; i < aIndent; i++) { + indentStr += ' '; + } + this.log(aLogLevel, indentStr, + this.accessibleToString(aAccessible), + '(' + this.statesToString(aAccessible) + ')'); + for (let i = 0; i < aAccessible.childCount; i++) { + this._dumpTreeInternal(aLogLevel, aAccessible.getChildAt(i), + aIndent + 1); + } + } +}; + +/** + * PivotContext: An object that generates and caches context information + * for a given accessible and its relationship with another accessible. + * + * If the given accessible is a label for a nested control, then this + * context will represent the nested control instead of the label. + * With the exception of bounds calculation, which will use the containing + * label. In this case the |accessible| field would be the embedded control, + * and the |accessibleForBounds| field would be the label. + */ +this.PivotContext = function PivotContext(aAccessible, aOldAccessible, // jshint ignore:line + aStartOffset, aEndOffset, aIgnoreAncestry = false, + aIncludeInvisible = false) { + this._accessible = aAccessible; + this._nestedControl = Utils.getEmbeddedControl(aAccessible); + this._oldAccessible = + this._isDefunct(aOldAccessible) ? null : aOldAccessible; + this.startOffset = aStartOffset; + this.endOffset = aEndOffset; + this._ignoreAncestry = aIgnoreAncestry; + this._includeInvisible = aIncludeInvisible; +}; + +PivotContext.prototype = { + get accessible() { + // If the current pivot accessible has a nested control, + // make this context use it publicly. + return this._nestedControl || this._accessible; + }, + + get oldAccessible() { + return this._oldAccessible; + }, + + get isNestedControl() { + return !!this._nestedControl; + }, + + get accessibleForBounds() { + return this._accessible; + }, + + get textAndAdjustedOffsets() { + if (this.startOffset === -1 && this.endOffset === -1) { + return null; + } + + if (!this._textAndAdjustedOffsets) { + let result = {startOffset: this.startOffset, + endOffset: this.endOffset, + text: this._accessible.QueryInterface(Ci.nsIAccessibleText). + getText(0, + Ci.nsIAccessibleText.TEXT_OFFSET_END_OF_TEXT)}; + let hypertextAcc = this._accessible.QueryInterface( + Ci.nsIAccessibleHyperText); + + // Iterate through the links in backwards order so text replacements don't + // affect the offsets of links yet to be processed. + for (let i = hypertextAcc.linkCount - 1; i >= 0; i--) { + let link = hypertextAcc.getLinkAt(i); + let linkText = ''; + if (link instanceof Ci.nsIAccessibleText) { + linkText = link.QueryInterface(Ci.nsIAccessibleText). + getText(0, + Ci.nsIAccessibleText.TEXT_OFFSET_END_OF_TEXT); + } + + let start = link.startIndex; + let end = link.endIndex; + for (let offset of ['startOffset', 'endOffset']) { + if (this[offset] >= end) { + result[offset] += linkText.length - (end - start); + } + } + result.text = result.text.substring(0, start) + linkText + + result.text.substring(end); + } + + this._textAndAdjustedOffsets = result; + } + + return this._textAndAdjustedOffsets; + }, + + /** + * Get a list of |aAccessible|'s ancestry up to the root. + * @param {nsIAccessible} aAccessible. + * @return {Array} Ancestry list. + */ + _getAncestry: function _getAncestry(aAccessible) { + let ancestry = []; + let parent = aAccessible; + try { + while (parent && (parent = parent.parent)) { + ancestry.push(parent); + } + } catch (x) { + // A defunct accessible will raise an exception geting parent. + Logger.debug('Failed to get parent:', x); + } + return ancestry.reverse(); + }, + + /** + * A list of the old accessible's ancestry. + */ + get oldAncestry() { + if (!this._oldAncestry) { + if (!this._oldAccessible || this._ignoreAncestry) { + this._oldAncestry = []; + } else { + this._oldAncestry = this._getAncestry(this._oldAccessible); + this._oldAncestry.push(this._oldAccessible); + } + } + return this._oldAncestry; + }, + + /** + * A list of the current accessible's ancestry. + */ + get currentAncestry() { + if (!this._currentAncestry) { + this._currentAncestry = this._ignoreAncestry ? [] : + this._getAncestry(this.accessible); + } + return this._currentAncestry; + }, + + /* + * This is a list of the accessible's ancestry up to the common ancestor + * of the accessible and the old accessible. It is useful for giving the + * user context as to where they are in the heirarchy. + */ + get newAncestry() { + if (!this._newAncestry) { + this._newAncestry = this._ignoreAncestry ? [] : + this.currentAncestry.filter( + (currentAncestor, i) => currentAncestor !== this.oldAncestry[i]); + } + return this._newAncestry; + }, + + /* + * Traverse the accessible's subtree in pre or post order. + * It only includes the accessible's visible chidren. + * Note: needSubtree is a function argument that can be used to determine + * whether aAccessible's subtree is required. + */ + _traverse: function* _traverse(aAccessible, aPreorder, aStop) { + if (aStop && aStop(aAccessible)) { + return; + } + let child = aAccessible.firstChild; + while (child) { + let include; + if (this._includeInvisible) { + include = true; + } else { + include = !Utils.isHidden(child); + } + if (include) { + if (aPreorder) { + yield child; + for (let node of this._traverse(child, aPreorder, aStop)) { + yield node; + } + } else { + for (let node of this._traverse(child, aPreorder, aStop)) { + yield node; + } + yield child; + } + } + child = child.nextSibling; + } + }, + + /** + * Get interaction hints for the context ancestry. + * @return {Array} Array of interaction hints. + */ + get interactionHints() { + let hints = []; + this.newAncestry.concat(this.accessible).reverse().forEach(aAccessible => { + let hint = Utils.getAttributes(aAccessible)['moz-hint']; + if (hint) { + hints.push(hint); + } else if (aAccessible.actionCount > 0) { + hints.push({ + string: Utils.AccService.getStringRole( + aAccessible.role).replace(/\s/g, '') + '-hint' + }); + } + }); + return hints; + }, + + /* + * A subtree generator function, used to generate a flattened + * list of the accessible's subtree in pre or post order. + * It only includes the accessible's visible chidren. + * @param {boolean} aPreorder A flag for traversal order. If true, traverse + * in preorder; if false, traverse in postorder. + * @param {function} aStop An optional function, indicating whether subtree + * traversal should stop. + */ + subtreeGenerator: function subtreeGenerator(aPreorder, aStop) { + return this._traverse(this.accessible, aPreorder, aStop); + }, + + getCellInfo: function getCellInfo(aAccessible) { + if (!this._cells) { + this._cells = new WeakMap(); + } + + let domNode = aAccessible.DOMNode; + if (this._cells.has(domNode)) { + return this._cells.get(domNode); + } + + let cellInfo = {}; + let getAccessibleCell = function getAccessibleCell(aAccessible) { + if (!aAccessible) { + return null; + } + if ([ + Roles.CELL, + Roles.COLUMNHEADER, + Roles.ROWHEADER, + Roles.MATHML_CELL + ].indexOf(aAccessible.role) < 0) { + return null; + } + try { + return aAccessible.QueryInterface(Ci.nsIAccessibleTableCell); + } catch (x) { + Logger.logException(x); + return null; + } + }; + let getHeaders = function* getHeaders(aHeaderCells) { + let enumerator = aHeaderCells.enumerate(); + while (enumerator.hasMoreElements()) { + yield enumerator.getNext().QueryInterface(Ci.nsIAccessible).name; + } + }; + + cellInfo.current = getAccessibleCell(aAccessible); + + if (!cellInfo.current) { + Logger.warning(aAccessible, + 'does not support nsIAccessibleTableCell interface.'); + this._cells.set(domNode, null); + return null; + } + + let table = cellInfo.current.table; + if (table.isProbablyForLayout()) { + this._cells.set(domNode, null); + return null; + } + + cellInfo.previous = null; + let oldAncestry = this.oldAncestry.reverse(); + let ancestor = oldAncestry.shift(); + while (!cellInfo.previous && ancestor) { + let cell = getAccessibleCell(ancestor); + if (cell && cell.table === table) { + cellInfo.previous = cell; + } + ancestor = oldAncestry.shift(); + } + + if (cellInfo.previous) { + cellInfo.rowChanged = cellInfo.current.rowIndex !== + cellInfo.previous.rowIndex; + cellInfo.columnChanged = cellInfo.current.columnIndex !== + cellInfo.previous.columnIndex; + } else { + cellInfo.rowChanged = true; + cellInfo.columnChanged = true; + } + + cellInfo.rowExtent = cellInfo.current.rowExtent; + cellInfo.columnExtent = cellInfo.current.columnExtent; + cellInfo.columnIndex = cellInfo.current.columnIndex; + cellInfo.rowIndex = cellInfo.current.rowIndex; + + cellInfo.columnHeaders = []; + if (cellInfo.columnChanged && cellInfo.current.role !== + Roles.COLUMNHEADER) { + cellInfo.columnHeaders = [...getHeaders(cellInfo.current.columnHeaderCells)]; + } + cellInfo.rowHeaders = []; + if (cellInfo.rowChanged && + (cellInfo.current.role === Roles.CELL || + cellInfo.current.role === Roles.MATHML_CELL)) { + cellInfo.rowHeaders = [...getHeaders(cellInfo.current.rowHeaderCells)]; + } + + this._cells.set(domNode, cellInfo); + return cellInfo; + }, + + get bounds() { + if (!this._bounds) { + this._bounds = Utils.getBounds(this.accessibleForBounds); + } + + return this._bounds.clone(); + }, + + _isDefunct: function _isDefunct(aAccessible) { + try { + return Utils.getState(aAccessible).contains(States.DEFUNCT); + } catch (x) { + return true; + } + } +}; + +this.PrefCache = function PrefCache(aName, aCallback, aRunCallbackNow) { // jshint ignore:line + this.name = aName; + this.callback = aCallback; + + let branch = Services.prefs; + this.value = this._getValue(branch); + + if (this.callback && aRunCallbackNow) { + try { + this.callback(this.name, this.value, true); + } catch (x) { + Logger.logException(x); + } + } + + branch.addObserver(aName, this, true); +}; + +PrefCache.prototype = { + _getValue: function _getValue(aBranch) { + try { + if (!this.type) { + this.type = aBranch.getPrefType(this.name); + } + switch (this.type) { + case Ci.nsIPrefBranch.PREF_STRING: + return aBranch.getCharPref(this.name); + case Ci.nsIPrefBranch.PREF_INT: + return aBranch.getIntPref(this.name); + case Ci.nsIPrefBranch.PREF_BOOL: + return aBranch.getBoolPref(this.name); + default: + return null; + } + } catch (x) { + // Pref does not exist. + return null; + } + }, + + observe: function observe(aSubject) { + this.value = this._getValue(aSubject.QueryInterface(Ci.nsIPrefBranch)); + Logger.info('pref changed', this.name, this.value); + if (this.callback) { + try { + this.callback(this.name, this.value, false); + } catch (x) { + Logger.logException(x); + } + } + }, + + QueryInterface : XPCOMUtils.generateQI([Ci.nsIObserver, + Ci.nsISupportsWeakReference]) +}; + +this.SettingCache = function SettingCache(aName, aCallback, aOptions = {}) { // jshint ignore:line + this.value = aOptions.defaultValue; + let runCallback = () => { + if (aCallback) { + aCallback(aName, this.value); + if (aOptions.callbackOnce) { + runCallback = () => {}; + } + } + }; + + let settings = Utils.win.navigator.mozSettings; + if (!settings) { + if (aOptions.callbackNow) { + runCallback(); + } + return; + } + + + let lock = settings.createLock(); + let req = lock.get(aName); + + req.addEventListener('success', () => { + this.value = req.result[aName] === undefined ? + aOptions.defaultValue : req.result[aName]; + if (aOptions.callbackNow) { + runCallback(); + } + }); + + settings.addObserver(aName, + (evt) => { + this.value = evt.settingValue; + runCallback(); + }); +}; diff --git a/accessible/jsat/content-script.js b/accessible/jsat/content-script.js new file mode 100644 index 000000000..6ef0dadc3 --- /dev/null +++ b/accessible/jsat/content-script.js @@ -0,0 +1,151 @@ +/* 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/. */ + +var Ci = Components.interfaces; +var Cu = Components.utils; + +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Logger', + 'resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Presentation', + 'resource://gre/modules/accessibility/Presentation.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Utils', + 'resource://gre/modules/accessibility/Utils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'EventManager', + 'resource://gre/modules/accessibility/EventManager.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'ContentControl', + 'resource://gre/modules/accessibility/ContentControl.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'Roles', + 'resource://gre/modules/accessibility/Constants.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'States', + 'resource://gre/modules/accessibility/Constants.jsm'); + +Logger.info('content-script.js', content.document.location); + +var eventManager = null; +var contentControl = null; + +function forwardToParent(aMessage) { + // XXX: This is a silly way to make a deep copy + let newJSON = JSON.parse(JSON.stringify(aMessage.json)); + newJSON.origin = 'child'; + sendAsyncMessage(aMessage.name, newJSON); +} + +function forwardToChild(aMessage, aListener, aVCPosition) { + let acc = aVCPosition || Utils.getVirtualCursor(content.document).position; + + if (!Utils.isAliveAndVisible(acc) || acc.role != Roles.INTERNAL_FRAME) { + return false; + } + + Logger.debug(() => { + return ['forwardToChild', Logger.accessibleToString(acc), + aMessage.name, JSON.stringify(aMessage.json, null, ' ')]; + }); + + let mm = Utils.getMessageManager(acc.DOMNode); + + if (aListener) { + mm.addMessageListener(aMessage.name, aListener); + } + + // XXX: This is a silly way to make a deep copy + let newJSON = JSON.parse(JSON.stringify(aMessage.json)); + newJSON.origin = 'parent'; + if (Utils.isContentProcess) { + // XXX: OOP content's screen offset is 0, + // so we remove the real screen offset here. + newJSON.x -= content.mozInnerScreenX; + newJSON.y -= content.mozInnerScreenY; + } + mm.sendAsyncMessage(aMessage.name, newJSON); + return true; +} + +function activateContextMenu(aMessage) { + let position = Utils.getVirtualCursor(content.document).position; + if (!forwardToChild(aMessage, activateContextMenu, position)) { + let center = Utils.getBounds(position, true).center(); + + let evt = content.document.createEvent('HTMLEvents'); + evt.initEvent('contextmenu', true, true); + evt.clientX = center.x; + evt.clientY = center.y; + position.DOMNode.dispatchEvent(evt); + } +} + +function presentCaretChange(aText, aOldOffset, aNewOffset) { + if (aOldOffset !== aNewOffset) { + let msg = Presentation.textSelectionChanged(aText, aNewOffset, aNewOffset, + aOldOffset, aOldOffset, true); + sendAsyncMessage('AccessFu:Present', msg); + } +} + +function scroll(aMessage) { + let position = Utils.getVirtualCursor(content.document).position; + if (!forwardToChild(aMessage, scroll, position)) { + sendAsyncMessage('AccessFu:DoScroll', + { bounds: Utils.getBounds(position, true), + page: aMessage.json.page, + horizontal: aMessage.json.horizontal }); + } +} + +addMessageListener( + 'AccessFu:Start', + function(m) { + if (m.json.logLevel) { + Logger.logLevel = Logger[m.json.logLevel]; + } + + Logger.debug('AccessFu:Start'); + if (m.json.buildApp) + Utils.MozBuildApp = m.json.buildApp; + + addMessageListener('AccessFu:ContextMenu', activateContextMenu); + addMessageListener('AccessFu:Scroll', scroll); + + if (!contentControl) { + contentControl = new ContentControl(this); + } + contentControl.start(); + + if (!eventManager) { + eventManager = new EventManager(this, contentControl); + } + eventManager.inTest = m.json.inTest; + eventManager.start(); + + function contentStarted() { + let accDoc = Utils.AccService.getAccessibleFor(content.document); + if (accDoc && !Utils.getState(accDoc).contains(States.BUSY)) { + sendAsyncMessage('AccessFu:ContentStarted'); + } else { + content.setTimeout(contentStarted, 0); + } + } + + if (m.json.inTest) { + // During a test we want to wait for the document to finish loading for + // consistency. + contentStarted(); + } + }); + +addMessageListener( + 'AccessFu:Stop', + function(m) { + Logger.debug('AccessFu:Stop'); + + removeMessageListener('AccessFu:ContextMenu', activateContextMenu); + removeMessageListener('AccessFu:Scroll', scroll); + + eventManager.stop(); + contentControl.stop(); + }); + +sendAsyncMessage('AccessFu:Ready'); diff --git a/accessible/jsat/jar.mn b/accessible/jsat/jar.mn new file mode 100644 index 000000000..970fb9a9b --- /dev/null +++ b/accessible/jsat/jar.mn @@ -0,0 +1,10 @@ +# 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/. + +toolkit.jar: + content/global/accessibility/AccessFu.css (AccessFu.css) + content/global/accessibility/content-script.js (content-script.js) + content/global/accessibility/virtual_cursor_move.ogg (sounds/virtual_cursor_move.ogg) + content/global/accessibility/virtual_cursor_key.ogg (sounds/virtual_cursor_key.ogg) + content/global/accessibility/clicked.ogg (sounds/clicked.ogg) diff --git a/accessible/jsat/moz.build b/accessible/jsat/moz.build new file mode 100644 index 000000000..b9051f532 --- /dev/null +++ b/accessible/jsat/moz.build @@ -0,0 +1,20 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +EXTRA_JS_MODULES.accessibility += [ + 'AccessFu.jsm', + 'Constants.jsm', + 'ContentControl.jsm', + 'EventManager.jsm', + 'Gestures.jsm', + 'OutputGenerator.jsm', + 'PointerAdapter.jsm', + 'Presentation.jsm', + 'Traversal.jsm', + 'Utils.jsm' +] + +JAR_MANIFESTS += ['jar.mn'] \ No newline at end of file diff --git a/accessible/jsat/sounds/clicked.ogg b/accessible/jsat/sounds/clicked.ogg new file mode 100644 index 000000000..68388018e Binary files /dev/null and b/accessible/jsat/sounds/clicked.ogg differ diff --git a/accessible/jsat/sounds/virtual_cursor_key.ogg b/accessible/jsat/sounds/virtual_cursor_key.ogg new file mode 100644 index 000000000..b29b55b44 Binary files /dev/null and b/accessible/jsat/sounds/virtual_cursor_key.ogg differ diff --git a/accessible/jsat/sounds/virtual_cursor_move.ogg b/accessible/jsat/sounds/virtual_cursor_move.ogg new file mode 100644 index 000000000..da9793460 Binary files /dev/null and b/accessible/jsat/sounds/virtual_cursor_move.ogg differ -- cgit v1.2.3