|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, |
|
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 'use strict'; |
|
6 |
|
7 const Cc = Components.classes; |
|
8 const Ci = Components.interfaces; |
|
9 const Cu = Components.utils; |
|
10 const Cr = Components.results; |
|
11 |
|
12 this.EXPORTED_SYMBOLS = ['AccessFu']; |
|
13 |
|
14 Cu.import('resource://gre/modules/Services.jsm'); |
|
15 |
|
16 Cu.import('resource://gre/modules/accessibility/Utils.jsm'); |
|
17 |
|
18 const ACCESSFU_DISABLE = 0; |
|
19 const ACCESSFU_ENABLE = 1; |
|
20 const ACCESSFU_AUTO = 2; |
|
21 |
|
22 const SCREENREADER_SETTING = 'accessibility.screenreader'; |
|
23 |
|
24 this.AccessFu = { |
|
25 /** |
|
26 * Initialize chrome-layer accessibility functionality. |
|
27 * If accessibility is enabled on the platform, then a special accessibility |
|
28 * mode is started. |
|
29 */ |
|
30 attach: function attach(aWindow) { |
|
31 Utils.init(aWindow); |
|
32 |
|
33 try { |
|
34 Services.androidBridge.handleGeckoMessage( |
|
35 { type: 'Accessibility:Ready' }); |
|
36 Services.obs.addObserver(this, 'Accessibility:Settings', false); |
|
37 } catch (x) { |
|
38 // Not on Android |
|
39 if (aWindow.navigator.mozSettings) { |
|
40 let lock = aWindow.navigator.mozSettings.createLock(); |
|
41 let req = lock.get(SCREENREADER_SETTING); |
|
42 req.addEventListener('success', () => { |
|
43 this._systemPref = req.result[SCREENREADER_SETTING]; |
|
44 this._enableOrDisable(); |
|
45 }); |
|
46 aWindow.navigator.mozSettings.addObserver( |
|
47 SCREENREADER_SETTING, this.handleEvent.bind(this)); |
|
48 } |
|
49 } |
|
50 |
|
51 this._activatePref = new PrefCache( |
|
52 'accessibility.accessfu.activate', this._enableOrDisable.bind(this)); |
|
53 |
|
54 this._enableOrDisable(); |
|
55 }, |
|
56 |
|
57 /** |
|
58 * Shut down chrome-layer accessibility functionality from the outside. |
|
59 */ |
|
60 detach: function detach() { |
|
61 // Avoid disabling twice. |
|
62 if (this._enabled) { |
|
63 this._disable(); |
|
64 } |
|
65 if (Utils.MozBuildApp === 'mobile/android') { |
|
66 Services.obs.removeObserver(this, 'Accessibility:Settings'); |
|
67 } else if (Utils.win.navigator.mozSettings) { |
|
68 Utils.win.navigator.mozSettings.removeObserver( |
|
69 SCREENREADER_SETTING, this.handleEvent.bind(this)); |
|
70 } |
|
71 delete this._activatePref; |
|
72 Utils.uninit(); |
|
73 }, |
|
74 |
|
75 /** |
|
76 * Start AccessFu mode, this primarily means controlling the virtual cursor |
|
77 * with arrow keys. |
|
78 */ |
|
79 _enable: function _enable() { |
|
80 if (this._enabled) |
|
81 return; |
|
82 this._enabled = true; |
|
83 |
|
84 Cu.import('resource://gre/modules/accessibility/Utils.jsm'); |
|
85 Cu.import('resource://gre/modules/accessibility/PointerAdapter.jsm'); |
|
86 Cu.import('resource://gre/modules/accessibility/Presentation.jsm'); |
|
87 |
|
88 Logger.info('Enabled'); |
|
89 |
|
90 for each (let mm in Utils.AllMessageManagers) { |
|
91 this._addMessageListeners(mm); |
|
92 this._loadFrameScript(mm); |
|
93 } |
|
94 |
|
95 // Add stylesheet |
|
96 let stylesheetURL = 'chrome://global/content/accessibility/AccessFu.css'; |
|
97 let stylesheet = Utils.win.document.createProcessingInstruction( |
|
98 'xml-stylesheet', 'href="' + stylesheetURL + '" type="text/css"'); |
|
99 Utils.win.document.insertBefore(stylesheet, Utils.win.document.firstChild); |
|
100 this.stylesheet = Cu.getWeakReference(stylesheet); |
|
101 |
|
102 |
|
103 // Populate quicknav modes |
|
104 this._quicknavModesPref = |
|
105 new PrefCache( |
|
106 'accessibility.accessfu.quicknav_modes', |
|
107 (aName, aValue) => { |
|
108 this.Input.quickNavMode.updateModes(aValue); |
|
109 }, true); |
|
110 |
|
111 // Check for output notification |
|
112 this._notifyOutputPref = |
|
113 new PrefCache('accessibility.accessfu.notify_output'); |
|
114 |
|
115 |
|
116 this.Input.start(); |
|
117 Output.start(); |
|
118 PointerAdapter.start(); |
|
119 |
|
120 Services.obs.addObserver(this, 'remote-browser-shown', false); |
|
121 Services.obs.addObserver(this, 'inprocess-browser-shown', false); |
|
122 Services.obs.addObserver(this, 'Accessibility:NextObject', false); |
|
123 Services.obs.addObserver(this, 'Accessibility:PreviousObject', false); |
|
124 Services.obs.addObserver(this, 'Accessibility:Focus', false); |
|
125 Services.obs.addObserver(this, 'Accessibility:ActivateObject', false); |
|
126 Services.obs.addObserver(this, 'Accessibility:LongPress', false); |
|
127 Services.obs.addObserver(this, 'Accessibility:MoveByGranularity', false); |
|
128 Utils.win.addEventListener('TabOpen', this); |
|
129 Utils.win.addEventListener('TabClose', this); |
|
130 Utils.win.addEventListener('TabSelect', this); |
|
131 |
|
132 if (this.readyCallback) { |
|
133 this.readyCallback(); |
|
134 delete this.readyCallback; |
|
135 } |
|
136 |
|
137 if (Utils.MozBuildApp !== 'mobile/android') { |
|
138 this.announce( |
|
139 Utils.stringBundle.GetStringFromName('screenReaderStarted')); |
|
140 } |
|
141 }, |
|
142 |
|
143 /** |
|
144 * Disable AccessFu and return to default interaction mode. |
|
145 */ |
|
146 _disable: function _disable() { |
|
147 if (!this._enabled) |
|
148 return; |
|
149 |
|
150 this._enabled = false; |
|
151 |
|
152 Logger.info('Disabled'); |
|
153 |
|
154 Utils.win.document.removeChild(this.stylesheet.get()); |
|
155 |
|
156 if (Utils.MozBuildApp !== 'mobile/android') { |
|
157 this.announce( |
|
158 Utils.stringBundle.GetStringFromName('screenReaderStopped')); |
|
159 } |
|
160 |
|
161 for each (let mm in Utils.AllMessageManagers) { |
|
162 mm.sendAsyncMessage('AccessFu:Stop'); |
|
163 this._removeMessageListeners(mm); |
|
164 } |
|
165 |
|
166 this.Input.stop(); |
|
167 Output.stop(); |
|
168 PointerAdapter.stop(); |
|
169 |
|
170 Utils.win.removeEventListener('TabOpen', this); |
|
171 Utils.win.removeEventListener('TabClose', this); |
|
172 Utils.win.removeEventListener('TabSelect', this); |
|
173 |
|
174 Services.obs.removeObserver(this, 'remote-browser-shown'); |
|
175 Services.obs.removeObserver(this, 'inprocess-browser-shown'); |
|
176 Services.obs.removeObserver(this, 'Accessibility:NextObject'); |
|
177 Services.obs.removeObserver(this, 'Accessibility:PreviousObject'); |
|
178 Services.obs.removeObserver(this, 'Accessibility:Focus'); |
|
179 Services.obs.removeObserver(this, 'Accessibility:ActivateObject'); |
|
180 Services.obs.removeObserver(this, 'Accessibility:LongPress'); |
|
181 Services.obs.removeObserver(this, 'Accessibility:MoveByGranularity'); |
|
182 |
|
183 delete this._quicknavModesPref; |
|
184 delete this._notifyOutputPref; |
|
185 |
|
186 if (this.doneCallback) { |
|
187 this.doneCallback(); |
|
188 delete this.doneCallback; |
|
189 } |
|
190 }, |
|
191 |
|
192 _enableOrDisable: function _enableOrDisable() { |
|
193 try { |
|
194 if (!this._activatePref) { |
|
195 return; |
|
196 } |
|
197 let activatePref = this._activatePref.value; |
|
198 if (activatePref == ACCESSFU_ENABLE || |
|
199 this._systemPref && activatePref == ACCESSFU_AUTO) |
|
200 this._enable(); |
|
201 else |
|
202 this._disable(); |
|
203 } catch (x) { |
|
204 dump('Error ' + x.message + ' ' + x.fileName + ':' + x.lineNumber); |
|
205 } |
|
206 }, |
|
207 |
|
208 receiveMessage: function receiveMessage(aMessage) { |
|
209 Logger.debug(() => { |
|
210 return ['Recieved', aMessage.name, JSON.stringify(aMessage.json)]; |
|
211 }); |
|
212 |
|
213 switch (aMessage.name) { |
|
214 case 'AccessFu:Ready': |
|
215 let mm = Utils.getMessageManager(aMessage.target); |
|
216 if (this._enabled) { |
|
217 mm.sendAsyncMessage('AccessFu:Start', |
|
218 {method: 'start', buildApp: Utils.MozBuildApp}); |
|
219 } |
|
220 break; |
|
221 case 'AccessFu:Present': |
|
222 this._output(aMessage.json, aMessage.target); |
|
223 break; |
|
224 case 'AccessFu:Input': |
|
225 this.Input.setEditState(aMessage.json); |
|
226 break; |
|
227 case 'AccessFu:ActivateContextMenu': |
|
228 this.Input.activateContextMenu(aMessage.json); |
|
229 break; |
|
230 case 'AccessFu:DoScroll': |
|
231 this.Input.doScroll(aMessage.json); |
|
232 break; |
|
233 } |
|
234 }, |
|
235 |
|
236 _output: function _output(aPresentationData, aBrowser) { |
|
237 for each (let presenter in aPresentationData) { |
|
238 if (!presenter) |
|
239 continue; |
|
240 |
|
241 try { |
|
242 Output[presenter.type](presenter.details, aBrowser); |
|
243 } catch (x) { |
|
244 Logger.logException(x); |
|
245 } |
|
246 } |
|
247 |
|
248 if (this._notifyOutputPref.value) { |
|
249 Services.obs.notifyObservers(null, 'accessfu-output', |
|
250 JSON.stringify(aPresentationData)); |
|
251 } |
|
252 }, |
|
253 |
|
254 _loadFrameScript: function _loadFrameScript(aMessageManager) { |
|
255 if (this._processedMessageManagers.indexOf(aMessageManager) < 0) { |
|
256 aMessageManager.loadFrameScript( |
|
257 'chrome://global/content/accessibility/content-script.js', true); |
|
258 this._processedMessageManagers.push(aMessageManager); |
|
259 } else if (this._enabled) { |
|
260 // If the content-script is already loaded and AccessFu is enabled, |
|
261 // send an AccessFu:Start message. |
|
262 aMessageManager.sendAsyncMessage('AccessFu:Start', |
|
263 {method: 'start', buildApp: Utils.MozBuildApp}); |
|
264 } |
|
265 }, |
|
266 |
|
267 _addMessageListeners: function _addMessageListeners(aMessageManager) { |
|
268 aMessageManager.addMessageListener('AccessFu:Present', this); |
|
269 aMessageManager.addMessageListener('AccessFu:Input', this); |
|
270 aMessageManager.addMessageListener('AccessFu:Ready', this); |
|
271 aMessageManager.addMessageListener('AccessFu:ActivateContextMenu', this); |
|
272 aMessageManager.addMessageListener('AccessFu:DoScroll', this); |
|
273 }, |
|
274 |
|
275 _removeMessageListeners: function _removeMessageListeners(aMessageManager) { |
|
276 aMessageManager.removeMessageListener('AccessFu:Present', this); |
|
277 aMessageManager.removeMessageListener('AccessFu:Input', this); |
|
278 aMessageManager.removeMessageListener('AccessFu:Ready', this); |
|
279 aMessageManager.removeMessageListener('AccessFu:ActivateContextMenu', this); |
|
280 aMessageManager.removeMessageListener('AccessFu:DoScroll', this); |
|
281 }, |
|
282 |
|
283 _handleMessageManager: function _handleMessageManager(aMessageManager) { |
|
284 if (this._enabled) { |
|
285 this._addMessageListeners(aMessageManager); |
|
286 } |
|
287 this._loadFrameScript(aMessageManager); |
|
288 }, |
|
289 |
|
290 observe: function observe(aSubject, aTopic, aData) { |
|
291 switch (aTopic) { |
|
292 case 'Accessibility:Settings': |
|
293 this._systemPref = JSON.parse(aData).enabled; |
|
294 this._enableOrDisable(); |
|
295 break; |
|
296 case 'Accessibility:NextObject': |
|
297 this.Input.moveCursor('moveNext', 'Simple', 'gesture'); |
|
298 break; |
|
299 case 'Accessibility:PreviousObject': |
|
300 this.Input.moveCursor('movePrevious', 'Simple', 'gesture'); |
|
301 break; |
|
302 case 'Accessibility:ActivateObject': |
|
303 this.Input.activateCurrent(JSON.parse(aData)); |
|
304 break; |
|
305 case 'Accessibility:LongPress': |
|
306 this.Input.sendContextMenuMessage(); |
|
307 break; |
|
308 case 'Accessibility:Focus': |
|
309 this._focused = JSON.parse(aData); |
|
310 if (this._focused) { |
|
311 this.autoMove({ forcePresent: true, noOpIfOnScreen: true }); |
|
312 } |
|
313 break; |
|
314 case 'Accessibility:MoveByGranularity': |
|
315 this.Input.moveByGranularity(JSON.parse(aData)); |
|
316 break; |
|
317 case 'remote-browser-shown': |
|
318 case 'inprocess-browser-shown': |
|
319 { |
|
320 // Ignore notifications that aren't from a BrowserOrApp |
|
321 let frameLoader = aSubject.QueryInterface(Ci.nsIFrameLoader); |
|
322 if (!frameLoader.ownerIsBrowserOrAppFrame) { |
|
323 return; |
|
324 } |
|
325 this._handleMessageManager(frameLoader.messageManager); |
|
326 break; |
|
327 } |
|
328 } |
|
329 }, |
|
330 |
|
331 handleEvent: function handleEvent(aEvent) { |
|
332 switch (aEvent.type) { |
|
333 case 'TabOpen': |
|
334 { |
|
335 let mm = Utils.getMessageManager(aEvent.target); |
|
336 this._handleMessageManager(mm); |
|
337 break; |
|
338 } |
|
339 case 'TabClose': |
|
340 { |
|
341 let mm = Utils.getMessageManager(aEvent.target); |
|
342 let mmIndex = this._processedMessageManagers.indexOf(mm); |
|
343 if (mmIndex > -1) { |
|
344 this._removeMessageListeners(mm); |
|
345 this._processedMessageManagers.splice(mmIndex, 1); |
|
346 } |
|
347 break; |
|
348 } |
|
349 case 'TabSelect': |
|
350 { |
|
351 if (this._focused) { |
|
352 // We delay this for half a second so the awesomebar could close, |
|
353 // and we could use the current coordinates for the content item. |
|
354 // XXX TODO figure out how to avoid magic wait here. |
|
355 this.autoMove({ |
|
356 delay: 500, |
|
357 forcePresent: true, |
|
358 noOpIfOnScreen: true, |
|
359 moveMethod: 'moveFirst' }); |
|
360 } |
|
361 break; |
|
362 } |
|
363 default: |
|
364 { |
|
365 // A settings change, it does not have an event type |
|
366 if (aEvent.settingName == SCREENREADER_SETTING) { |
|
367 this._systemPref = aEvent.settingValue; |
|
368 this._enableOrDisable(); |
|
369 } |
|
370 break; |
|
371 } |
|
372 } |
|
373 }, |
|
374 |
|
375 autoMove: function autoMove(aOptions) { |
|
376 let mm = Utils.getMessageManager(Utils.CurrentBrowser); |
|
377 mm.sendAsyncMessage('AccessFu:AutoMove', aOptions); |
|
378 }, |
|
379 |
|
380 announce: function announce(aAnnouncement) { |
|
381 this._output(Presentation.announce(aAnnouncement), Utils.CurrentBrowser); |
|
382 }, |
|
383 |
|
384 // So we don't enable/disable twice |
|
385 _enabled: false, |
|
386 |
|
387 // Layerview is focused |
|
388 _focused: false, |
|
389 |
|
390 // Keep track of message managers tha already have a 'content-script.js' |
|
391 // injected. |
|
392 _processedMessageManagers: [], |
|
393 |
|
394 /** |
|
395 * Adjusts the given bounds relative to the given browser. Converts from screen |
|
396 * or device pixels to either device or CSS pixels. |
|
397 * @param {Rect} aJsonBounds the bounds to adjust |
|
398 * @param {browser} aBrowser the browser we want the bounds relative to |
|
399 * @param {bool} aToCSSPixels whether to convert to CSS pixels (as opposed to |
|
400 * device pixels) |
|
401 * @param {bool} aFromDevicePixels whether to convert from device pixels (as |
|
402 * opposed to screen pixels) |
|
403 */ |
|
404 adjustContentBounds: function(aJsonBounds, aBrowser, aToCSSPixels, aFromDevicePixels) { |
|
405 let bounds = new Rect(aJsonBounds.left, aJsonBounds.top, |
|
406 aJsonBounds.right - aJsonBounds.left, |
|
407 aJsonBounds.bottom - aJsonBounds.top); |
|
408 let win = Utils.win; |
|
409 let dpr = win.devicePixelRatio; |
|
410 let vp = Utils.getViewport(win); |
|
411 let offset = { left: -win.mozInnerScreenX, top: -win.mozInnerScreenY }; |
|
412 |
|
413 if (!aBrowser.contentWindow) { |
|
414 // OOP browser, add offset of browser. |
|
415 // The offset of the browser element in relation to its parent window. |
|
416 let clientRect = aBrowser.getBoundingClientRect(); |
|
417 let win = aBrowser.ownerDocument.defaultView; |
|
418 offset.left += clientRect.left + win.mozInnerScreenX; |
|
419 offset.top += clientRect.top + win.mozInnerScreenY; |
|
420 } |
|
421 |
|
422 // Here we scale from screen pixels to layout device pixels by dividing by |
|
423 // the resolution (caused by pinch-zooming). The resolution is the viewport |
|
424 // zoom divided by the devicePixelRatio. If there's no viewport, then we're |
|
425 // on a platform without pinch-zooming and we can just ignore this. |
|
426 if (!aFromDevicePixels && vp) { |
|
427 bounds = bounds.scale(vp.zoom / dpr, vp.zoom / dpr); |
|
428 } |
|
429 |
|
430 // Add the offset; the offset is in CSS pixels, so multiply the |
|
431 // devicePixelRatio back in before adding to preserve unit consistency. |
|
432 bounds = bounds.translate(offset.left * dpr, offset.top * dpr); |
|
433 |
|
434 // If we want to get to CSS pixels from device pixels, this needs to be |
|
435 // further divided by the devicePixelRatio due to widget scaling. |
|
436 if (aToCSSPixels) { |
|
437 bounds = bounds.scale(1 / dpr, 1 / dpr); |
|
438 } |
|
439 |
|
440 return bounds.expandToIntegers(); |
|
441 } |
|
442 }; |
|
443 |
|
444 var Output = { |
|
445 brailleState: { |
|
446 startOffset: 0, |
|
447 endOffset: 0, |
|
448 text: '', |
|
449 selectionStart: 0, |
|
450 selectionEnd: 0, |
|
451 |
|
452 init: function init(aOutput) { |
|
453 if (aOutput && 'output' in aOutput) { |
|
454 this.startOffset = aOutput.startOffset; |
|
455 this.endOffset = aOutput.endOffset; |
|
456 // We need to append a space at the end so that the routing key corresponding |
|
457 // to the end of the output (i.e. the space) can be hit to move the caret there. |
|
458 this.text = aOutput.output + ' '; |
|
459 this.selectionStart = typeof aOutput.selectionStart === 'number' ? |
|
460 aOutput.selectionStart : this.selectionStart; |
|
461 this.selectionEnd = typeof aOutput.selectionEnd === 'number' ? |
|
462 aOutput.selectionEnd : this.selectionEnd; |
|
463 |
|
464 return { text: this.text, |
|
465 selectionStart: this.selectionStart, |
|
466 selectionEnd: this.selectionEnd }; |
|
467 } |
|
468 |
|
469 return null; |
|
470 }, |
|
471 |
|
472 adjustText: function adjustText(aText) { |
|
473 let newBraille = []; |
|
474 let braille = {}; |
|
475 |
|
476 let prefix = this.text.substring(0, this.startOffset).trim(); |
|
477 if (prefix) { |
|
478 prefix += ' '; |
|
479 newBraille.push(prefix); |
|
480 } |
|
481 |
|
482 newBraille.push(aText); |
|
483 |
|
484 let suffix = this.text.substring(this.endOffset).trim(); |
|
485 if (suffix) { |
|
486 suffix = ' ' + suffix; |
|
487 newBraille.push(suffix); |
|
488 } |
|
489 |
|
490 this.startOffset = braille.startOffset = prefix.length; |
|
491 this.text = braille.text = newBraille.join('') + ' '; |
|
492 this.endOffset = braille.endOffset = braille.text.length - suffix.length; |
|
493 braille.selectionStart = this.selectionStart; |
|
494 braille.selectionEnd = this.selectionEnd; |
|
495 |
|
496 return braille; |
|
497 }, |
|
498 |
|
499 adjustSelection: function adjustSelection(aSelection) { |
|
500 let braille = {}; |
|
501 |
|
502 braille.startOffset = this.startOffset; |
|
503 braille.endOffset = this.endOffset; |
|
504 braille.text = this.text; |
|
505 this.selectionStart = braille.selectionStart = aSelection.selectionStart + this.startOffset; |
|
506 this.selectionEnd = braille.selectionEnd = aSelection.selectionEnd + this.startOffset; |
|
507 |
|
508 return braille; |
|
509 } |
|
510 }, |
|
511 |
|
512 speechHelper: { |
|
513 EARCONS: ['virtual_cursor_move.ogg', |
|
514 'virtual_cursor_key.ogg', |
|
515 'clicked.ogg'], |
|
516 |
|
517 earconBuffers: {}, |
|
518 |
|
519 inited: false, |
|
520 |
|
521 webspeechEnabled: false, |
|
522 |
|
523 deferredOutputs: [], |
|
524 |
|
525 init: function init() { |
|
526 let window = Utils.win; |
|
527 this.webspeechEnabled = !!window.speechSynthesis && |
|
528 !!window.SpeechSynthesisUtterance; |
|
529 |
|
530 let settingsToGet = 2; |
|
531 let settingsCallback = (aName, aSetting) => { |
|
532 if (--settingsToGet > 0) { |
|
533 return; |
|
534 } |
|
535 |
|
536 this.inited = true; |
|
537 |
|
538 for (let actions of this.deferredOutputs) { |
|
539 this.output(actions); |
|
540 } |
|
541 }; |
|
542 |
|
543 this._volumeSetting = new SettingCache( |
|
544 'accessibility.screenreader-volume', settingsCallback, |
|
545 { defaultValue: 1, callbackNow: true, callbackOnce: true }); |
|
546 this._rateSetting = new SettingCache( |
|
547 'accessibility.screenreader-rate', settingsCallback, |
|
548 { defaultValue: 0, callbackNow: true, callbackOnce: true }); |
|
549 |
|
550 for (let earcon of this.EARCONS) { |
|
551 let earconName = /(^.*)\..*$/.exec(earcon)[1]; |
|
552 this.earconBuffers[earconName] = new WeakMap(); |
|
553 this.earconBuffers[earconName].set( |
|
554 window, new window.Audio('chrome://global/content/accessibility/' + earcon)); |
|
555 } |
|
556 }, |
|
557 |
|
558 uninit: function uninit() { |
|
559 if (this.inited) { |
|
560 delete this._volumeSetting; |
|
561 delete this._rateSetting; |
|
562 } |
|
563 this.inited = false; |
|
564 }, |
|
565 |
|
566 output: function output(aActions) { |
|
567 if (!this.inited) { |
|
568 this.deferredOutputs.push(aActions); |
|
569 return; |
|
570 } |
|
571 |
|
572 for (let action of aActions) { |
|
573 let window = Utils.win; |
|
574 Logger.debug('tts.' + action.method, '"' + action.data + '"', |
|
575 JSON.stringify(action.options)); |
|
576 |
|
577 if (!action.options.enqueue && this.webspeechEnabled) { |
|
578 window.speechSynthesis.cancel(); |
|
579 } |
|
580 |
|
581 if (action.method === 'speak' && this.webspeechEnabled) { |
|
582 let utterance = new window.SpeechSynthesisUtterance(action.data); |
|
583 let requestedRate = this._rateSetting.value; |
|
584 utterance.volume = this._volumeSetting.value; |
|
585 utterance.rate = requestedRate >= 0 ? |
|
586 requestedRate + 1 : 1 / (Math.abs(requestedRate) + 1); |
|
587 window.speechSynthesis.speak(utterance); |
|
588 } else if (action.method === 'playEarcon') { |
|
589 let audioBufferWeakMap = this.earconBuffers[action.data]; |
|
590 if (audioBufferWeakMap) { |
|
591 let node = audioBufferWeakMap.get(window).cloneNode(false); |
|
592 node.volume = this._volumeSetting.value; |
|
593 node.play(); |
|
594 } |
|
595 } |
|
596 } |
|
597 } |
|
598 }, |
|
599 |
|
600 start: function start() { |
|
601 Cu.import('resource://gre/modules/Geometry.jsm'); |
|
602 this.speechHelper.init(); |
|
603 }, |
|
604 |
|
605 stop: function stop() { |
|
606 if (this.highlightBox) { |
|
607 Utils.win.document.documentElement.removeChild(this.highlightBox.get()); |
|
608 delete this.highlightBox; |
|
609 } |
|
610 |
|
611 if (this.announceBox) { |
|
612 Utils.win.document.documentElement.removeChild(this.announceBox.get()); |
|
613 delete this.announceBox; |
|
614 } |
|
615 |
|
616 this.speechHelper.uninit(); |
|
617 }, |
|
618 |
|
619 Speech: function Speech(aDetails, aBrowser) { |
|
620 this.speechHelper.output(aDetails.actions); |
|
621 }, |
|
622 |
|
623 Visual: function Visual(aDetails, aBrowser) { |
|
624 switch (aDetails.method) { |
|
625 case 'showBounds': |
|
626 { |
|
627 let highlightBox = null; |
|
628 if (!this.highlightBox) { |
|
629 // Add highlight box |
|
630 highlightBox = Utils.win.document. |
|
631 createElementNS('http://www.w3.org/1999/xhtml', 'div'); |
|
632 Utils.win.document.documentElement.appendChild(highlightBox); |
|
633 highlightBox.id = 'virtual-cursor-box'; |
|
634 |
|
635 // Add highlight inset for inner shadow |
|
636 let inset = Utils.win.document. |
|
637 createElementNS('http://www.w3.org/1999/xhtml', 'div'); |
|
638 inset.id = 'virtual-cursor-inset'; |
|
639 |
|
640 highlightBox.appendChild(inset); |
|
641 this.highlightBox = Cu.getWeakReference(highlightBox); |
|
642 } else { |
|
643 highlightBox = this.highlightBox.get(); |
|
644 } |
|
645 |
|
646 let padding = aDetails.padding; |
|
647 let r = AccessFu.adjustContentBounds(aDetails.bounds, aBrowser, true); |
|
648 |
|
649 // First hide it to avoid flickering when changing the style. |
|
650 highlightBox.style.display = 'none'; |
|
651 highlightBox.style.top = (r.top - padding) + 'px'; |
|
652 highlightBox.style.left = (r.left - padding) + 'px'; |
|
653 highlightBox.style.width = (r.width + padding*2) + 'px'; |
|
654 highlightBox.style.height = (r.height + padding*2) + 'px'; |
|
655 highlightBox.style.display = 'block'; |
|
656 |
|
657 break; |
|
658 } |
|
659 case 'hideBounds': |
|
660 { |
|
661 let highlightBox = this.highlightBox ? this.highlightBox.get() : null; |
|
662 if (highlightBox) |
|
663 highlightBox.style.display = 'none'; |
|
664 break; |
|
665 } |
|
666 case 'showAnnouncement': |
|
667 { |
|
668 let announceBox = this.announceBox ? this.announceBox.get() : null; |
|
669 if (!announceBox) { |
|
670 announceBox = Utils.win.document. |
|
671 createElementNS('http://www.w3.org/1999/xhtml', 'div'); |
|
672 announceBox.id = 'announce-box'; |
|
673 Utils.win.document.documentElement.appendChild(announceBox); |
|
674 this.announceBox = Cu.getWeakReference(announceBox); |
|
675 } |
|
676 |
|
677 announceBox.innerHTML = '<div>' + aDetails.text + '</div>'; |
|
678 announceBox.classList.add('showing'); |
|
679 |
|
680 if (this._announceHideTimeout) |
|
681 Utils.win.clearTimeout(this._announceHideTimeout); |
|
682 |
|
683 if (aDetails.duration > 0) |
|
684 this._announceHideTimeout = Utils.win.setTimeout( |
|
685 function () { |
|
686 announceBox.classList.remove('showing'); |
|
687 this._announceHideTimeout = 0; |
|
688 }.bind(this), aDetails.duration); |
|
689 break; |
|
690 } |
|
691 case 'hideAnnouncement': |
|
692 { |
|
693 let announceBox = this.announceBox ? this.announceBox.get() : null; |
|
694 if (announceBox) |
|
695 announceBox.classList.remove('showing'); |
|
696 break; |
|
697 } |
|
698 } |
|
699 }, |
|
700 |
|
701 get androidBridge() { |
|
702 delete this.androidBridge; |
|
703 if (Utils.MozBuildApp === 'mobile/android') { |
|
704 this.androidBridge = Services.androidBridge; |
|
705 } else { |
|
706 this.androidBridge = null; |
|
707 } |
|
708 return this.androidBridge; |
|
709 }, |
|
710 |
|
711 Android: function Android(aDetails, aBrowser) { |
|
712 const ANDROID_VIEW_TEXT_CHANGED = 0x10; |
|
713 const ANDROID_VIEW_TEXT_SELECTION_CHANGED = 0x2000; |
|
714 |
|
715 if (!this.androidBridge) { |
|
716 return; |
|
717 } |
|
718 |
|
719 for each (let androidEvent in aDetails) { |
|
720 androidEvent.type = 'Accessibility:Event'; |
|
721 if (androidEvent.bounds) |
|
722 androidEvent.bounds = AccessFu.adjustContentBounds(androidEvent.bounds, aBrowser); |
|
723 |
|
724 switch(androidEvent.eventType) { |
|
725 case ANDROID_VIEW_TEXT_CHANGED: |
|
726 androidEvent.brailleOutput = this.brailleState.adjustText(androidEvent.text); |
|
727 break; |
|
728 case ANDROID_VIEW_TEXT_SELECTION_CHANGED: |
|
729 androidEvent.brailleOutput = this.brailleState.adjustSelection(androidEvent.brailleOutput); |
|
730 break; |
|
731 default: |
|
732 androidEvent.brailleOutput = this.brailleState.init(androidEvent.brailleOutput); |
|
733 break; |
|
734 } |
|
735 this.androidBridge.handleGeckoMessage(androidEvent); |
|
736 } |
|
737 }, |
|
738 |
|
739 Haptic: function Haptic(aDetails, aBrowser) { |
|
740 Utils.win.navigator.vibrate(aDetails.pattern); |
|
741 }, |
|
742 |
|
743 Braille: function Braille(aDetails, aBrowser) { |
|
744 Logger.debug('Braille output: ' + aDetails.text); |
|
745 } |
|
746 }; |
|
747 |
|
748 var Input = { |
|
749 editState: {}, |
|
750 |
|
751 start: function start() { |
|
752 // XXX: This is too disruptive on desktop for now. |
|
753 // Might need to add special modifiers. |
|
754 if (Utils.MozBuildApp != 'browser') { |
|
755 Utils.win.document.addEventListener('keypress', this, true); |
|
756 } |
|
757 Utils.win.addEventListener('mozAccessFuGesture', this, true); |
|
758 }, |
|
759 |
|
760 stop: function stop() { |
|
761 if (Utils.MozBuildApp != 'browser') { |
|
762 Utils.win.document.removeEventListener('keypress', this, true); |
|
763 } |
|
764 Utils.win.removeEventListener('mozAccessFuGesture', this, true); |
|
765 }, |
|
766 |
|
767 handleEvent: function Input_handleEvent(aEvent) { |
|
768 try { |
|
769 switch (aEvent.type) { |
|
770 case 'keypress': |
|
771 this._handleKeypress(aEvent); |
|
772 break; |
|
773 case 'mozAccessFuGesture': |
|
774 this._handleGesture(aEvent.detail); |
|
775 break; |
|
776 } |
|
777 } catch (x) { |
|
778 Logger.logException(x); |
|
779 } |
|
780 }, |
|
781 |
|
782 _handleGesture: function _handleGesture(aGesture) { |
|
783 let gestureName = aGesture.type + aGesture.touches.length; |
|
784 Logger.debug('Gesture', aGesture.type, |
|
785 '(fingers: ' + aGesture.touches.length + ')'); |
|
786 |
|
787 switch (gestureName) { |
|
788 case 'dwell1': |
|
789 case 'explore1': |
|
790 this.moveToPoint('Simple', aGesture.touches[0].x, |
|
791 aGesture.touches[0].y); |
|
792 break; |
|
793 case 'doubletap1': |
|
794 this.activateCurrent(); |
|
795 break; |
|
796 case 'doubletaphold1': |
|
797 this.sendContextMenuMessage(); |
|
798 break; |
|
799 case 'swiperight1': |
|
800 this.moveCursor('moveNext', 'Simple', 'gestures'); |
|
801 break; |
|
802 case 'swipeleft1': |
|
803 this.moveCursor('movePrevious', 'Simple', 'gesture'); |
|
804 break; |
|
805 case 'swipeup1': |
|
806 this.contextAction('backward'); |
|
807 break; |
|
808 case 'swipedown1': |
|
809 this.contextAction('forward'); |
|
810 break; |
|
811 case 'exploreend1': |
|
812 case 'dwellend1': |
|
813 this.activateCurrent(null, true); |
|
814 break; |
|
815 case 'swiperight2': |
|
816 this.sendScrollMessage(-1, true); |
|
817 break; |
|
818 case 'swipedown2': |
|
819 this.sendScrollMessage(-1); |
|
820 break; |
|
821 case 'swipeleft2': |
|
822 this.sendScrollMessage(1, true); |
|
823 break; |
|
824 case 'swipeup2': |
|
825 this.sendScrollMessage(1); |
|
826 break; |
|
827 case 'explore2': |
|
828 Utils.CurrentBrowser.contentWindow.scrollBy( |
|
829 -aGesture.deltaX, -aGesture.deltaY); |
|
830 break; |
|
831 case 'swiperight3': |
|
832 this.moveCursor('moveNext', this.quickNavMode.current, 'gesture'); |
|
833 break; |
|
834 case 'swipeleft3': |
|
835 this.moveCursor('movePrevious', this.quickNavMode.current, 'gesture'); |
|
836 break; |
|
837 case 'swipedown3': |
|
838 this.quickNavMode.next(); |
|
839 AccessFu.announce('quicknav_' + this.quickNavMode.current); |
|
840 break; |
|
841 case 'swipeup3': |
|
842 this.quickNavMode.previous(); |
|
843 AccessFu.announce('quicknav_' + this.quickNavMode.current); |
|
844 break; |
|
845 } |
|
846 }, |
|
847 |
|
848 _handleKeypress: function _handleKeypress(aEvent) { |
|
849 let target = aEvent.target; |
|
850 |
|
851 // Ignore keys with modifiers so the content could take advantage of them. |
|
852 if (aEvent.ctrlKey || aEvent.altKey || aEvent.metaKey) |
|
853 return; |
|
854 |
|
855 switch (aEvent.keyCode) { |
|
856 case 0: |
|
857 // an alphanumeric key was pressed, handle it separately. |
|
858 // If it was pressed with either alt or ctrl, just pass through. |
|
859 // If it was pressed with meta, pass the key on without the meta. |
|
860 if (this.editState.editing) |
|
861 return; |
|
862 |
|
863 let key = String.fromCharCode(aEvent.charCode); |
|
864 try { |
|
865 let [methodName, rule] = this.keyMap[key]; |
|
866 this.moveCursor(methodName, rule, 'keyboard'); |
|
867 } catch (x) { |
|
868 return; |
|
869 } |
|
870 break; |
|
871 case aEvent.DOM_VK_RIGHT: |
|
872 if (this.editState.editing) { |
|
873 if (!this.editState.atEnd) |
|
874 // Don't move forward if caret is not at end of entry. |
|
875 // XXX: Fix for rtl |
|
876 return; |
|
877 else |
|
878 target.blur(); |
|
879 } |
|
880 this.moveCursor(aEvent.shiftKey ? 'moveLast' : 'moveNext', 'Simple', 'keyboard'); |
|
881 break; |
|
882 case aEvent.DOM_VK_LEFT: |
|
883 if (this.editState.editing) { |
|
884 if (!this.editState.atStart) |
|
885 // Don't move backward if caret is not at start of entry. |
|
886 // XXX: Fix for rtl |
|
887 return; |
|
888 else |
|
889 target.blur(); |
|
890 } |
|
891 this.moveCursor(aEvent.shiftKey ? 'moveFirst' : 'movePrevious', 'Simple', 'keyboard'); |
|
892 break; |
|
893 case aEvent.DOM_VK_UP: |
|
894 if (this.editState.multiline) { |
|
895 if (!this.editState.atStart) |
|
896 // Don't blur content if caret is not at start of text area. |
|
897 return; |
|
898 else |
|
899 target.blur(); |
|
900 } |
|
901 |
|
902 if (Utils.MozBuildApp == 'mobile/android') |
|
903 // Return focus to native Android browser chrome. |
|
904 Services.androidBridge.handleGeckoMessage( |
|
905 { type: 'ToggleChrome:Focus' }); |
|
906 break; |
|
907 case aEvent.DOM_VK_RETURN: |
|
908 if (this.editState.editing) |
|
909 return; |
|
910 this.activateCurrent(); |
|
911 break; |
|
912 default: |
|
913 return; |
|
914 } |
|
915 |
|
916 aEvent.preventDefault(); |
|
917 aEvent.stopPropagation(); |
|
918 }, |
|
919 |
|
920 moveToPoint: function moveToPoint(aRule, aX, aY) { |
|
921 let mm = Utils.getMessageManager(Utils.CurrentBrowser); |
|
922 mm.sendAsyncMessage('AccessFu:MoveToPoint', {rule: aRule, |
|
923 x: aX, y: aY, |
|
924 origin: 'top'}); |
|
925 }, |
|
926 |
|
927 moveCursor: function moveCursor(aAction, aRule, aInputType) { |
|
928 let mm = Utils.getMessageManager(Utils.CurrentBrowser); |
|
929 mm.sendAsyncMessage('AccessFu:MoveCursor', |
|
930 {action: aAction, rule: aRule, |
|
931 origin: 'top', inputType: aInputType}); |
|
932 }, |
|
933 |
|
934 contextAction: function contextAction(aDirection) { |
|
935 // XXX: For now, the only supported context action is adjusting a range. |
|
936 let mm = Utils.getMessageManager(Utils.CurrentBrowser); |
|
937 mm.sendAsyncMessage('AccessFu:AdjustRange', {direction: aDirection}); |
|
938 }, |
|
939 |
|
940 moveByGranularity: function moveByGranularity(aDetails) { |
|
941 const MOVEMENT_GRANULARITY_PARAGRAPH = 8; |
|
942 |
|
943 if (!this.editState.editing) { |
|
944 if (aDetails.granularity === MOVEMENT_GRANULARITY_PARAGRAPH) { |
|
945 this.moveCursor('move' + aDetails.direction, 'Paragraph', 'gesture'); |
|
946 return; |
|
947 } |
|
948 } else { |
|
949 aDetails.atStart = this.editState.atStart; |
|
950 aDetails.atEnd = this.editState.atEnd; |
|
951 } |
|
952 |
|
953 let mm = Utils.getMessageManager(Utils.CurrentBrowser); |
|
954 let type = this.editState.editing ? 'AccessFu:MoveCaret' : |
|
955 'AccessFu:MoveByGranularity'; |
|
956 mm.sendAsyncMessage(type, aDetails); |
|
957 }, |
|
958 |
|
959 activateCurrent: function activateCurrent(aData, aActivateIfKey = false) { |
|
960 let mm = Utils.getMessageManager(Utils.CurrentBrowser); |
|
961 let offset = aData && typeof aData.keyIndex === 'number' ? |
|
962 aData.keyIndex - Output.brailleState.startOffset : -1; |
|
963 |
|
964 mm.sendAsyncMessage('AccessFu:Activate', |
|
965 {offset: offset, activateIfKey: aActivateIfKey}); |
|
966 }, |
|
967 |
|
968 sendContextMenuMessage: function sendContextMenuMessage() { |
|
969 let mm = Utils.getMessageManager(Utils.CurrentBrowser); |
|
970 mm.sendAsyncMessage('AccessFu:ContextMenu', {}); |
|
971 }, |
|
972 |
|
973 activateContextMenu: function activateContextMenu(aDetails) { |
|
974 if (Utils.MozBuildApp === 'mobile/android') { |
|
975 let p = AccessFu.adjustContentBounds(aDetails.bounds, Utils.CurrentBrowser, |
|
976 true, true).center(); |
|
977 Services.obs.notifyObservers(null, 'Gesture:LongPress', |
|
978 JSON.stringify({x: p.x, y: p.y})); |
|
979 } |
|
980 }, |
|
981 |
|
982 setEditState: function setEditState(aEditState) { |
|
983 this.editState = aEditState; |
|
984 }, |
|
985 |
|
986 // XXX: This is here for backwards compatability with screen reader simulator |
|
987 // it should be removed when the extension is updated on amo. |
|
988 scroll: function scroll(aPage, aHorizontal) { |
|
989 this.sendScrollMessage(aPage, aHorizontal); |
|
990 }, |
|
991 |
|
992 sendScrollMessage: function sendScrollMessage(aPage, aHorizontal) { |
|
993 let mm = Utils.getMessageManager(Utils.CurrentBrowser); |
|
994 mm.sendAsyncMessage('AccessFu:Scroll', {page: aPage, horizontal: aHorizontal, origin: 'top'}); |
|
995 }, |
|
996 |
|
997 doScroll: function doScroll(aDetails) { |
|
998 let horizontal = aDetails.horizontal; |
|
999 let page = aDetails.page; |
|
1000 let p = AccessFu.adjustContentBounds(aDetails.bounds, Utils.CurrentBrowser, |
|
1001 true, true).center(); |
|
1002 let wu = Utils.win.QueryInterface(Ci.nsIInterfaceRequestor). |
|
1003 getInterface(Ci.nsIDOMWindowUtils); |
|
1004 wu.sendWheelEvent(p.x, p.y, |
|
1005 horizontal ? page : 0, horizontal ? 0 : page, 0, |
|
1006 Utils.win.WheelEvent.DOM_DELTA_PAGE, 0, 0, 0, 0); |
|
1007 }, |
|
1008 |
|
1009 get keyMap() { |
|
1010 delete this.keyMap; |
|
1011 this.keyMap = { |
|
1012 a: ['moveNext', 'Anchor'], |
|
1013 A: ['movePrevious', 'Anchor'], |
|
1014 b: ['moveNext', 'Button'], |
|
1015 B: ['movePrevious', 'Button'], |
|
1016 c: ['moveNext', 'Combobox'], |
|
1017 C: ['movePrevious', 'Combobox'], |
|
1018 d: ['moveNext', 'Landmark'], |
|
1019 D: ['movePrevious', 'Landmark'], |
|
1020 e: ['moveNext', 'Entry'], |
|
1021 E: ['movePrevious', 'Entry'], |
|
1022 f: ['moveNext', 'FormElement'], |
|
1023 F: ['movePrevious', 'FormElement'], |
|
1024 g: ['moveNext', 'Graphic'], |
|
1025 G: ['movePrevious', 'Graphic'], |
|
1026 h: ['moveNext', 'Heading'], |
|
1027 H: ['movePrevious', 'Heading'], |
|
1028 i: ['moveNext', 'ListItem'], |
|
1029 I: ['movePrevious', 'ListItem'], |
|
1030 k: ['moveNext', 'Link'], |
|
1031 K: ['movePrevious', 'Link'], |
|
1032 l: ['moveNext', 'List'], |
|
1033 L: ['movePrevious', 'List'], |
|
1034 p: ['moveNext', 'PageTab'], |
|
1035 P: ['movePrevious', 'PageTab'], |
|
1036 r: ['moveNext', 'RadioButton'], |
|
1037 R: ['movePrevious', 'RadioButton'], |
|
1038 s: ['moveNext', 'Separator'], |
|
1039 S: ['movePrevious', 'Separator'], |
|
1040 t: ['moveNext', 'Table'], |
|
1041 T: ['movePrevious', 'Table'], |
|
1042 x: ['moveNext', 'Checkbox'], |
|
1043 X: ['movePrevious', 'Checkbox'] |
|
1044 }; |
|
1045 |
|
1046 return this.keyMap; |
|
1047 }, |
|
1048 |
|
1049 quickNavMode: { |
|
1050 get current() { |
|
1051 return this.modes[this._currentIndex]; |
|
1052 }, |
|
1053 |
|
1054 previous: function quickNavMode_previous() { |
|
1055 if (--this._currentIndex < 0) |
|
1056 this._currentIndex = this.modes.length - 1; |
|
1057 }, |
|
1058 |
|
1059 next: function quickNavMode_next() { |
|
1060 if (++this._currentIndex >= this.modes.length) |
|
1061 this._currentIndex = 0; |
|
1062 }, |
|
1063 |
|
1064 updateModes: function updateModes(aModes) { |
|
1065 if (aModes) { |
|
1066 this.modes = aModes.split(','); |
|
1067 } else { |
|
1068 this.modes = []; |
|
1069 } |
|
1070 }, |
|
1071 |
|
1072 _currentIndex: -1 |
|
1073 } |
|
1074 }; |
|
1075 AccessFu.Input = Input; |