diff options
Diffstat (limited to 'accessible/jsat/ContentControl.jsm')
-rw-r--r-- | accessible/jsat/ContentControl.jsm | 528 |
1 files changed, 528 insertions, 0 deletions
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 + ]) +}; |