|
1 // -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*- |
|
2 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
3 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
5 |
|
6 /** |
|
7 * Import this module through |
|
8 * |
|
9 * Components.utils.import("resource://gre/modules/SpatialNavigation.jsm"); |
|
10 * |
|
11 * Usage: (Literal class) |
|
12 * |
|
13 * SpatialNavigation.init(browser_element, optional_callback); |
|
14 * |
|
15 * optional_callback will be called when a new element is focused. |
|
16 * |
|
17 * function optional_callback(element) {} |
|
18 */ |
|
19 |
|
20 "use strict"; |
|
21 |
|
22 this.EXPORTED_SYMBOLS = ["SpatialNavigation"]; |
|
23 |
|
24 var SpatialNavigation = { |
|
25 init: function(browser, callback) { |
|
26 browser.addEventListener("keydown", function (event) { |
|
27 _onInputKeyPress(event, callback); |
|
28 }, true); |
|
29 } |
|
30 }; |
|
31 |
|
32 // Private stuff |
|
33 |
|
34 const Cc = Components.classes; |
|
35 const Ci = Components.interfaces; |
|
36 const Cu = Components.utils; |
|
37 |
|
38 Cu["import"]("resource://gre/modules/Services.jsm", this); |
|
39 |
|
40 let eventListenerService = Cc["@mozilla.org/eventlistenerservice;1"] |
|
41 .getService(Ci.nsIEventListenerService); |
|
42 let focusManager = Cc["@mozilla.org/focus-manager;1"] |
|
43 .getService(Ci.nsIFocusManager); |
|
44 let windowMediator = Cc['@mozilla.org/appshell/window-mediator;1'] |
|
45 .getService(Ci.nsIWindowMediator); |
|
46 |
|
47 // Debug helpers: |
|
48 function dump(a) { |
|
49 Services.console.logStringMessage("SpatialNavigation: " + a); |
|
50 } |
|
51 |
|
52 function dumpRect(desc, rect) { |
|
53 dump(desc + " " + Math.round(rect.left) + " " + Math.round(rect.top) + " " + |
|
54 Math.round(rect.right) + " " + Math.round(rect.bottom) + " width:" + |
|
55 Math.round(rect.width) + " height:" + Math.round(rect.height)); |
|
56 } |
|
57 |
|
58 function dumpNodeCoord(desc, node) { |
|
59 let rect = node.getBoundingClientRect(); |
|
60 dump(desc + " " + node.tagName + " x:" + Math.round(rect.left + rect.width/2) + |
|
61 " y:" + Math.round(rect.top + rect.height / 2)); |
|
62 } |
|
63 |
|
64 // modifier values |
|
65 |
|
66 const kAlt = "alt"; |
|
67 const kShift = "shift"; |
|
68 const kCtrl = "ctrl"; |
|
69 const kNone = "none"; |
|
70 |
|
71 function _onInputKeyPress (event, callback) { |
|
72 //If Spatial Navigation isn't enabled, return. |
|
73 if (!PrefObserver['enabled']) { |
|
74 return; |
|
75 } |
|
76 |
|
77 // Use whatever key value is available (either keyCode or charCode). |
|
78 // It might be useful for addons or whoever wants to set different |
|
79 // key to be used here (e.g. "a", "F1", "arrowUp", ...). |
|
80 var key = event.which || event.keyCode; |
|
81 |
|
82 if (key != PrefObserver['keyCodeDown'] && |
|
83 key != PrefObserver['keyCodeRight'] && |
|
84 key != PrefObserver['keyCodeUp'] && |
|
85 key != PrefObserver['keyCodeLeft'] && |
|
86 key != PrefObserver['keyCodeReturn']) { |
|
87 return; |
|
88 } |
|
89 |
|
90 if (key == PrefObserver['keyCodeReturn']) { |
|
91 // We report presses of the action button on a gamepad "A" as the return |
|
92 // key to the DOM. The behaviour of hitting the return key and clicking an |
|
93 // element is the same for some elements, but not all, so we handle the |
|
94 // ones we want (like the Select element) here: |
|
95 if (event.target instanceof Ci.nsIDOMHTMLSelectElement && |
|
96 event.target.click) { |
|
97 event.target.click(); |
|
98 event.stopPropagation(); |
|
99 event.preventDefault(); |
|
100 return; |
|
101 } else { |
|
102 // Leave the action key press to get reported to the DOM as a return |
|
103 // keypress. |
|
104 return; |
|
105 } |
|
106 } |
|
107 |
|
108 // If it is not using the modifiers it should, return. |
|
109 if (!event.altKey && PrefObserver['modifierAlt'] || |
|
110 !event.shiftKey && PrefObserver['modifierShift'] || |
|
111 !event.crtlKey && PrefObserver['modifierCtrl']) { |
|
112 return; |
|
113 } |
|
114 |
|
115 let currentlyFocused = event.target; |
|
116 let currentlyFocusedWindow = currentlyFocused.ownerDocument.defaultView; |
|
117 let bestElementToFocus = null; |
|
118 |
|
119 // If currentlyFocused is an nsIDOMHTMLBodyElement then the page has just been |
|
120 // loaded, and this is the first keypress in the page. |
|
121 if (currentlyFocused instanceof Ci.nsIDOMHTMLBodyElement) { |
|
122 focusManager.moveFocus(currentlyFocusedWindow, null, focusManager.MOVEFOCUS_FIRST, 0); |
|
123 event.stopPropagation(); |
|
124 event.preventDefault(); |
|
125 return; |
|
126 } |
|
127 |
|
128 if ((currentlyFocused instanceof Ci.nsIDOMHTMLInputElement && |
|
129 currentlyFocused.mozIsTextField(false)) || |
|
130 currentlyFocused instanceof Ci.nsIDOMHTMLTextAreaElement) { |
|
131 // If there is a text selection, remain in the element. |
|
132 if (currentlyFocused.selectionEnd - currentlyFocused.selectionStart != 0) { |
|
133 return; |
|
134 } |
|
135 |
|
136 // If there is no text, there is nothing special to do. |
|
137 if (currentlyFocused.textLength > 0) { |
|
138 if (key == PrefObserver['keyCodeRight'] || |
|
139 key == PrefObserver['keyCodeDown'] ) { |
|
140 // We are moving forward into the document. |
|
141 if (currentlyFocused.textLength != currentlyFocused.selectionEnd) { |
|
142 return; |
|
143 } |
|
144 } else if (currentlyFocused.selectionStart != 0) { |
|
145 return; |
|
146 } |
|
147 } |
|
148 } |
|
149 |
|
150 let windowUtils = currentlyFocusedWindow.QueryInterface(Ci.nsIInterfaceRequestor) |
|
151 .getInterface(Ci.nsIDOMWindowUtils); |
|
152 let cssPageRect = _getRootBounds(windowUtils); |
|
153 let searchRect = _getSearchRect(currentlyFocused, key, cssPageRect); |
|
154 |
|
155 let nodes = {}; |
|
156 nodes.length = 0; |
|
157 |
|
158 let searchRectOverflows = false; |
|
159 |
|
160 while (!bestElementToFocus && !searchRectOverflows) { |
|
161 switch (key) { |
|
162 case PrefObserver['keyCodeLeft']: |
|
163 case PrefObserver['keyCodeRight']: { |
|
164 if (searchRect.top < cssPageRect.top && |
|
165 searchRect.bottom > cssPageRect.bottom) { |
|
166 searchRectOverflows = true; |
|
167 } |
|
168 break; |
|
169 } |
|
170 case PrefObserver['keyCodeUp']: |
|
171 case PrefObserver['keyCodeDown']: { |
|
172 if (searchRect.left < cssPageRect.left && |
|
173 searchRect.right > cssPageRect.right) { |
|
174 searchRectOverflows = true; |
|
175 } |
|
176 break; |
|
177 } |
|
178 } |
|
179 |
|
180 nodes = windowUtils.nodesFromRect(searchRect.left, searchRect.top, |
|
181 0, searchRect.width, searchRect.height, 0, |
|
182 true, false); |
|
183 // Make the search rectangle "wider": double it's size in the direction |
|
184 // that is not the keypress. |
|
185 switch (key) { |
|
186 case PrefObserver['keyCodeLeft']: |
|
187 case PrefObserver['keyCodeRight']: { |
|
188 searchRect.top = searchRect.top - (searchRect.height / 2); |
|
189 searchRect.bottom = searchRect.top + (searchRect.height * 2); |
|
190 searchRect.height = searchRect.height * 2; |
|
191 break; |
|
192 } |
|
193 case PrefObserver['keyCodeUp']: |
|
194 case PrefObserver['keyCodeDown']: { |
|
195 searchRect.left = searchRect.left - (searchRect.width / 2); |
|
196 searchRect.right = searchRect.left + (searchRect.width * 2); |
|
197 searchRect.width = searchRect.width * 2; |
|
198 break; |
|
199 } |
|
200 } |
|
201 bestElementToFocus = _getBestToFocus(nodes, key, currentlyFocused); |
|
202 } |
|
203 |
|
204 |
|
205 if (bestElementToFocus === null) { |
|
206 // Couldn't find an element to focus. |
|
207 return; |
|
208 } |
|
209 |
|
210 focusManager.setFocus(bestElementToFocus, focusManager.FLAG_SHOWRING); |
|
211 |
|
212 //if it is a text element, select all. |
|
213 if ((bestElementToFocus instanceof Ci.nsIDOMHTMLInputElement && |
|
214 bestElementToFocus.mozIsTextField(false)) || |
|
215 bestElementToFocus instanceof Ci.nsIDOMHTMLTextAreaElement) { |
|
216 bestElementToFocus.selectionStart = 0; |
|
217 bestElementToFocus.selectionEnd = bestElementToFocus.textLength; |
|
218 } |
|
219 |
|
220 if (callback != undefined) { |
|
221 callback(bestElementToFocus); |
|
222 } |
|
223 |
|
224 event.preventDefault(); |
|
225 event.stopPropagation(); |
|
226 } |
|
227 |
|
228 // Returns the bounds of the page relative to the viewport. |
|
229 function _getRootBounds(windowUtils) { |
|
230 let cssPageRect = windowUtils.getRootBounds(); |
|
231 |
|
232 let scrollX = {}; |
|
233 let scrollY = {}; |
|
234 windowUtils.getScrollXY(false, scrollX, scrollY); |
|
235 |
|
236 let cssPageRectCopy = {}; |
|
237 |
|
238 cssPageRectCopy.right = cssPageRect.right - scrollX.value; |
|
239 cssPageRectCopy.left = cssPageRect.left - scrollX.value; |
|
240 cssPageRectCopy.top = cssPageRect.top - scrollY.value; |
|
241 cssPageRectCopy.bottom = cssPageRect.bottom - scrollY.value; |
|
242 cssPageRectCopy.width = cssPageRect.width; |
|
243 cssPageRectCopy.height = cssPageRect.height; |
|
244 |
|
245 return cssPageRectCopy; |
|
246 } |
|
247 |
|
248 // Returns the best node to focus from the list of nodes returned by the hit |
|
249 // test. |
|
250 function _getBestToFocus(nodes, key, currentlyFocused) { |
|
251 let best = null; |
|
252 let bestDist; |
|
253 let bestMid; |
|
254 let nodeMid; |
|
255 let currentlyFocusedMid = _getMidpoint(currentlyFocused); |
|
256 let currentlyFocusedRect = currentlyFocused.getBoundingClientRect(); |
|
257 |
|
258 for (let i = 0; i < nodes.length; i++) { |
|
259 // Reject the currentlyFocused, and all node types we can't focus |
|
260 if (!_canFocus(nodes[i]) || nodes[i] === currentlyFocused) { |
|
261 continue; |
|
262 } |
|
263 |
|
264 // Reject all nodes that aren't "far enough" in the direction of the |
|
265 // keypress |
|
266 nodeMid = _getMidpoint(nodes[i]); |
|
267 switch (key) { |
|
268 case PrefObserver['keyCodeLeft']: |
|
269 if (nodeMid.x >= (currentlyFocusedMid.x - currentlyFocusedRect.width / 2)) { |
|
270 continue; |
|
271 } |
|
272 break; |
|
273 case PrefObserver['keyCodeRight']: |
|
274 if (nodeMid.x <= (currentlyFocusedMid.x + currentlyFocusedRect.width / 2)) { |
|
275 continue; |
|
276 } |
|
277 break; |
|
278 |
|
279 case PrefObserver['keyCodeUp']: |
|
280 if (nodeMid.y >= (currentlyFocusedMid.y - currentlyFocusedRect.height / 2)) { |
|
281 continue; |
|
282 } |
|
283 break; |
|
284 case PrefObserver['keyCodeDown']: |
|
285 if (nodeMid.y <= (currentlyFocusedMid.y + currentlyFocusedRect.height / 2)) { |
|
286 continue; |
|
287 } |
|
288 break; |
|
289 } |
|
290 |
|
291 // Initialize best to the first viable value: |
|
292 if (!best) { |
|
293 best = nodes[i]; |
|
294 bestDist = _spatialDistance(best, currentlyFocused); |
|
295 continue; |
|
296 } |
|
297 |
|
298 // Of the remaining nodes, pick the one closest to the currently focused |
|
299 // node. |
|
300 let curDist = _spatialDistance(nodes[i], currentlyFocused); |
|
301 if (curDist > bestDist) { |
|
302 continue; |
|
303 } |
|
304 |
|
305 bestMid = _getMidpoint(best); |
|
306 switch (key) { |
|
307 case PrefObserver['keyCodeLeft']: |
|
308 if (nodeMid.x > bestMid.x) { |
|
309 best = nodes[i]; |
|
310 bestDist = curDist; |
|
311 } |
|
312 break; |
|
313 case PrefObserver['keyCodeRight']: |
|
314 if (nodeMid.x < bestMid.x) { |
|
315 best = nodes[i]; |
|
316 bestDist = curDist; |
|
317 } |
|
318 break; |
|
319 case PrefObserver['keyCodeUp']: |
|
320 if (nodeMid.y > bestMid.y) { |
|
321 best = nodes[i]; |
|
322 bestDist = curDist; |
|
323 } |
|
324 break; |
|
325 case PrefObserver['keyCodeDown']: |
|
326 if (nodeMid.y < bestMid.y) { |
|
327 best = nodes[i]; |
|
328 bestDist = curDist; |
|
329 } |
|
330 break; |
|
331 } |
|
332 } |
|
333 return best; |
|
334 } |
|
335 |
|
336 // Returns the midpoint of a node. |
|
337 function _getMidpoint(node) { |
|
338 let mid = {}; |
|
339 let box = node.getBoundingClientRect(); |
|
340 mid.x = box.left + (box.width / 2); |
|
341 mid.y = box.top + (box.height / 2); |
|
342 |
|
343 return mid; |
|
344 } |
|
345 |
|
346 // Returns true if the node is a type that we want to focus, false otherwise. |
|
347 function _canFocus(node) { |
|
348 if (node instanceof Ci.nsIDOMHTMLLinkElement || |
|
349 node instanceof Ci.nsIDOMHTMLAnchorElement) { |
|
350 return true; |
|
351 } |
|
352 if ((node instanceof Ci.nsIDOMHTMLButtonElement || |
|
353 node instanceof Ci.nsIDOMHTMLInputElement || |
|
354 node instanceof Ci.nsIDOMHTMLLinkElement || |
|
355 node instanceof Ci.nsIDOMHTMLOptGroupElement || |
|
356 node instanceof Ci.nsIDOMHTMLSelectElement || |
|
357 node instanceof Ci.nsIDOMHTMLTextAreaElement) && |
|
358 node.disabled === false) { |
|
359 return true; |
|
360 } |
|
361 return false; |
|
362 } |
|
363 |
|
364 // Returns a rectangle that extends to the end of the screen in the direction that |
|
365 // the key is pressed. |
|
366 function _getSearchRect(currentlyFocused, key, cssPageRect) { |
|
367 let currentlyFocusedRect = currentlyFocused.getBoundingClientRect(); |
|
368 |
|
369 let newRect = {}; |
|
370 newRect.left = currentlyFocusedRect.left; |
|
371 newRect.top = currentlyFocusedRect.top; |
|
372 newRect.right = currentlyFocusedRect.right; |
|
373 newRect.bottom = currentlyFocusedRect.bottom; |
|
374 newRect.width = currentlyFocusedRect.width; |
|
375 newRect.height = currentlyFocusedRect.height; |
|
376 |
|
377 switch (key) { |
|
378 case PrefObserver['keyCodeLeft']: |
|
379 newRect.left = cssPageRect.left; |
|
380 newRect.width = newRect.right - newRect.left; |
|
381 break; |
|
382 |
|
383 case PrefObserver['keyCodeRight']: |
|
384 newRect.right = cssPageRect.right; |
|
385 newRect.width = newRect.right - newRect.left; |
|
386 break; |
|
387 |
|
388 case PrefObserver['keyCodeUp']: |
|
389 newRect.top = cssPageRect.top; |
|
390 newRect.height = newRect.bottom - newRect.top; |
|
391 break; |
|
392 |
|
393 case PrefObserver['keyCodeDown']: |
|
394 newRect.bottom = cssPageRect.bottom; |
|
395 newRect.height = newRect.bottom - newRect.top; |
|
396 break; |
|
397 } |
|
398 return newRect; |
|
399 } |
|
400 |
|
401 // Gets the distance between two points a and b. |
|
402 function _spatialDistance(a, b) { |
|
403 let mida = _getMidpoint(a); |
|
404 let midb = _getMidpoint(b); |
|
405 |
|
406 return Math.round(Math.pow(mida.x - midb.x, 2) + |
|
407 Math.pow(mida.y - midb.y, 2)); |
|
408 } |
|
409 |
|
410 // Snav preference observer |
|
411 var PrefObserver = { |
|
412 register: function() { |
|
413 this.prefService = Cc["@mozilla.org/preferences-service;1"] |
|
414 .getService(Ci.nsIPrefService); |
|
415 |
|
416 this._branch = this.prefService.getBranch("snav."); |
|
417 this._branch.QueryInterface(Ci.nsIPrefBranch2); |
|
418 this._branch.addObserver("", this, false); |
|
419 |
|
420 // set current or default pref values |
|
421 this.observe(null, "nsPref:changed", "enabled"); |
|
422 this.observe(null, "nsPref:changed", "xulContentEnabled"); |
|
423 this.observe(null, "nsPref:changed", "keyCode.modifier"); |
|
424 this.observe(null, "nsPref:changed", "keyCode.right"); |
|
425 this.observe(null, "nsPref:changed", "keyCode.up"); |
|
426 this.observe(null, "nsPref:changed", "keyCode.down"); |
|
427 this.observe(null, "nsPref:changed", "keyCode.left"); |
|
428 this.observe(null, "nsPref:changed", "keyCode.return"); |
|
429 }, |
|
430 |
|
431 observe: function(aSubject, aTopic, aData) { |
|
432 if (aTopic != "nsPref:changed") { |
|
433 return; |
|
434 } |
|
435 |
|
436 // aSubject is the nsIPrefBranch we're observing (after appropriate QI) |
|
437 // aData is the name of the pref that's been changed (relative to aSubject) |
|
438 switch (aData) { |
|
439 case "enabled": |
|
440 try { |
|
441 this.enabled = this._branch.getBoolPref("enabled"); |
|
442 } catch(e) { |
|
443 this.enabled = false; |
|
444 } |
|
445 break; |
|
446 |
|
447 case "xulContentEnabled": |
|
448 try { |
|
449 this.xulContentEnabled = this._branch.getBoolPref("xulContentEnabled"); |
|
450 } catch(e) { |
|
451 this.xulContentEnabled = false; |
|
452 } |
|
453 break; |
|
454 |
|
455 case "keyCode.modifier": { |
|
456 let keyCodeModifier; |
|
457 try { |
|
458 keyCodeModifier = this._branch.getCharPref("keyCode.modifier"); |
|
459 |
|
460 // resetting modifiers |
|
461 this.modifierAlt = false; |
|
462 this.modifierShift = false; |
|
463 this.modifierCtrl = false; |
|
464 |
|
465 if (keyCodeModifier != this.kNone) { |
|
466 // we are using '+' as a separator in about:config. |
|
467 let mods = keyCodeModifier.split(/\++/); |
|
468 for (let i = 0; i < mods.length; i++) { |
|
469 let mod = mods[i].toLowerCase(); |
|
470 if (mod === "") |
|
471 continue; |
|
472 else if (mod == kAlt) |
|
473 this.modifierAlt = true; |
|
474 else if (mod == kShift) |
|
475 this.modifierShift = true; |
|
476 else if (mod == kCtrl) |
|
477 this.modifierCtrl = true; |
|
478 else { |
|
479 keyCodeModifier = kNone; |
|
480 break; |
|
481 } |
|
482 } |
|
483 } |
|
484 } catch(e) { } |
|
485 break; |
|
486 } |
|
487 |
|
488 case "keyCode.up": |
|
489 try { |
|
490 this.keyCodeUp = this._branch.getIntPref("keyCode.up"); |
|
491 } catch(e) { |
|
492 this.keyCodeUp = Ci.nsIDOMKeyEvent.DOM_VK_UP; |
|
493 } |
|
494 break; |
|
495 case "keyCode.down": |
|
496 try { |
|
497 this.keyCodeDown = this._branch.getIntPref("keyCode.down"); |
|
498 } catch(e) { |
|
499 this.keyCodeDown = Ci.nsIDOMKeyEvent.DOM_VK_DOWN; |
|
500 } |
|
501 break; |
|
502 case "keyCode.left": |
|
503 try { |
|
504 this.keyCodeLeft = this._branch.getIntPref("keyCode.left"); |
|
505 } catch(e) { |
|
506 this.keyCodeLeft = Ci.nsIDOMKeyEvent.DOM_VK_LEFT; |
|
507 } |
|
508 break; |
|
509 case "keyCode.right": |
|
510 try { |
|
511 this.keyCodeRight = this._branch.getIntPref("keyCode.right"); |
|
512 } catch(e) { |
|
513 this.keyCodeRight = Ci.nsIDOMKeyEvent.DOM_VK_RIGHT; |
|
514 } |
|
515 break; |
|
516 case "keyCode.return": |
|
517 try { |
|
518 this.keyCodeReturn = this._branch.getIntPref("keyCode.return"); |
|
519 } catch(e) { |
|
520 this.keyCodeReturn = Ci.nsIDOMKeyEvent.DOM_VK_RETURN; |
|
521 } |
|
522 break; |
|
523 } |
|
524 } |
|
525 }; |
|
526 |
|
527 PrefObserver.register(); |