diff options
Diffstat (limited to 'toolkit/modules/SpatialNavigation.jsm')
-rw-r--r-- | toolkit/modules/SpatialNavigation.jsm | 606 |
1 files changed, 606 insertions, 0 deletions
diff --git a/toolkit/modules/SpatialNavigation.jsm b/toolkit/modules/SpatialNavigation.jsm new file mode 100644 index 000000000..c6f18a84f --- /dev/null +++ b/toolkit/modules/SpatialNavigation.jsm @@ -0,0 +1,606 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +/* 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/. */ + +/** + * Import this module through + * + * Components.utils.import("resource://gre/modules/SpatialNavigation.jsm"); + * + * Usage: (Literal class) + * + * SpatialNavigation.init(browser_element, optional_callback); + * + * optional_callback will be called when a new element is focused. + * + * function optional_callback(element) {} + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["SpatialNavigation"]; + +var SpatialNavigation = { + init: function(browser, callback) { + browser.addEventListener("keydown", function (event) { + _onInputKeyPress(event, callback); + }, true); + } +}; + +// Private stuff + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +Cu["import"]("resource://gre/modules/Services.jsm", this); + +var eventListenerService = Cc["@mozilla.org/eventlistenerservice;1"] + .getService(Ci.nsIEventListenerService); +var focusManager = Cc["@mozilla.org/focus-manager;1"] + .getService(Ci.nsIFocusManager); +var windowMediator = Cc['@mozilla.org/appshell/window-mediator;1'] + .getService(Ci.nsIWindowMediator); + +// Debug helpers: +function dump(a) { + Services.console.logStringMessage("SpatialNavigation: " + a); +} + +function dumpRect(desc, rect) { + dump(desc + " " + Math.round(rect.left) + " " + Math.round(rect.top) + " " + + Math.round(rect.right) + " " + Math.round(rect.bottom) + " width:" + + Math.round(rect.width) + " height:" + Math.round(rect.height)); +} + +function dumpNodeCoord(desc, node) { + let rect = node.getBoundingClientRect(); + dump(desc + " " + node.tagName + " x:" + Math.round(rect.left + rect.width/2) + + " y:" + Math.round(rect.top + rect.height / 2)); +} + +// modifier values + +const kAlt = "alt"; +const kShift = "shift"; +const kCtrl = "ctrl"; +const kNone = "none"; + +function _onInputKeyPress (event, callback) { + // If Spatial Navigation isn't enabled, return. + if (!PrefObserver['enabled']) { + return; + } + + // Use whatever key value is available (either keyCode or charCode). + // It might be useful for addons or whoever wants to set different + // key to be used here (e.g. "a", "F1", "arrowUp", ...). + var key = event.which || event.keyCode; + + if (key != PrefObserver['keyCodeDown'] && + key != PrefObserver['keyCodeRight'] && + key != PrefObserver['keyCodeUp'] && + key != PrefObserver['keyCodeLeft'] && + key != PrefObserver['keyCodeReturn']) { + return; + } + + if (key == PrefObserver['keyCodeReturn']) { + // We report presses of the action button on a gamepad "A" as the return + // key to the DOM. The behaviour of hitting the return key and clicking an + // element is the same for some elements, but not all, so we handle the + // ones we want (like the Select element) here: + if (event.target instanceof Ci.nsIDOMHTMLSelectElement && + event.target.click) { + event.target.click(); + event.stopPropagation(); + event.preventDefault(); + return; + } + // Leave the action key press to get reported to the DOM as a return + // keypress. + return; + } + + // If it is not using the modifiers it should, return. + if (!event.altKey && PrefObserver['modifierAlt'] || + !event.shiftKey && PrefObserver['modifierShift'] || + !event.crtlKey && PrefObserver['modifierCtrl']) { + return; + } + + let currentlyFocused = event.target; + let currentlyFocusedWindow = currentlyFocused.ownerDocument.defaultView; + let bestElementToFocus = null; + + // If currentlyFocused is an nsIDOMHTMLBodyElement then the page has just been + // loaded, and this is the first keypress in the page. + if (currentlyFocused instanceof Ci.nsIDOMHTMLBodyElement) { + focusManager.moveFocus(currentlyFocusedWindow, null, focusManager.MOVEFOCUS_FIRST, 0); + event.stopPropagation(); + event.preventDefault(); + return; + } + + if ((currentlyFocused instanceof Ci.nsIDOMHTMLInputElement && + currentlyFocused.mozIsTextField(false)) || + currentlyFocused instanceof Ci.nsIDOMHTMLTextAreaElement) { + // If there is a text selection, remain in the element. + if (currentlyFocused.selectionEnd - currentlyFocused.selectionStart != 0) { + return; + } + + // If there is no text, there is nothing special to do. + if (currentlyFocused.textLength > 0) { + if (key == PrefObserver['keyCodeRight'] || + key == PrefObserver['keyCodeDown'] ) { + // We are moving forward into the document. + if (currentlyFocused.textLength != currentlyFocused.selectionEnd) { + return; + } + } else if (currentlyFocused.selectionStart != 0) { + return; + } + } + } + + let windowUtils = currentlyFocusedWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + let cssPageRect = _getRootBounds(windowUtils); + let searchRect = _getSearchRect(currentlyFocused, key, cssPageRect); + + let nodes = {}; + nodes.length = 0; + + let searchRectOverflows = false; + + while (!bestElementToFocus && !searchRectOverflows) { + switch (key) { + case PrefObserver['keyCodeLeft']: + case PrefObserver['keyCodeRight']: { + if (searchRect.top < cssPageRect.top && + searchRect.bottom > cssPageRect.bottom) { + searchRectOverflows = true; + } + break; + } + case PrefObserver['keyCodeUp']: + case PrefObserver['keyCodeDown']: { + if (searchRect.left < cssPageRect.left && + searchRect.right > cssPageRect.right) { + searchRectOverflows = true; + } + break; + } + } + + nodes = windowUtils.nodesFromRect(searchRect.left, searchRect.top, + 0, searchRect.width, searchRect.height, 0, + true, false); + // Make the search rectangle "wider": double it's size in the direction + // that is not the keypress. + switch (key) { + case PrefObserver['keyCodeLeft']: + case PrefObserver['keyCodeRight']: { + searchRect.top = searchRect.top - (searchRect.height / 2); + searchRect.bottom = searchRect.top + (searchRect.height * 2); + searchRect.height = searchRect.height * 2; + break; + } + case PrefObserver['keyCodeUp']: + case PrefObserver['keyCodeDown']: { + searchRect.left = searchRect.left - (searchRect.width / 2); + searchRect.right = searchRect.left + (searchRect.width * 2); + searchRect.width = searchRect.width * 2; + break; + } + } + bestElementToFocus = _getBestToFocus(nodes, key, currentlyFocused); + } + + + if (bestElementToFocus === null) { + // Couldn't find an element to focus. + return; + } + + focusManager.setFocus(bestElementToFocus, focusManager.FLAG_SHOWRING); + + // if it is a text element, select all. + if ((bestElementToFocus instanceof Ci.nsIDOMHTMLInputElement && + bestElementToFocus.mozIsTextField(false)) || + bestElementToFocus instanceof Ci.nsIDOMHTMLTextAreaElement) { + bestElementToFocus.selectionStart = 0; + bestElementToFocus.selectionEnd = bestElementToFocus.textLength; + } + + if (callback != undefined) { + callback(bestElementToFocus); + } + + event.preventDefault(); + event.stopPropagation(); +} + +// Returns the bounds of the page relative to the viewport. +function _getRootBounds(windowUtils) { + let cssPageRect = windowUtils.getRootBounds(); + + let scrollX = {}; + let scrollY = {}; + windowUtils.getScrollXY(false, scrollX, scrollY); + + let cssPageRectCopy = {}; + + cssPageRectCopy.right = cssPageRect.right - scrollX.value; + cssPageRectCopy.left = cssPageRect.left - scrollX.value; + cssPageRectCopy.top = cssPageRect.top - scrollY.value; + cssPageRectCopy.bottom = cssPageRect.bottom - scrollY.value; + cssPageRectCopy.width = cssPageRect.width; + cssPageRectCopy.height = cssPageRect.height; + + return cssPageRectCopy; +} + +// Returns the best node to focus from the list of nodes returned by the hit +// test. +function _getBestToFocus(nodes, key, currentlyFocused) { + let best = null; + let bestDist; + let bestMid; + let nodeMid; + let currentlyFocusedMid = _getMidpoint(currentlyFocused); + let currentlyFocusedRect = currentlyFocused.getBoundingClientRect(); + + for (let i = 0; i < nodes.length; i++) { + // Reject the currentlyFocused, and all node types we can't focus + if (!_canFocus(nodes[i]) || nodes[i] === currentlyFocused) { + continue; + } + + // Reject all nodes that aren't "far enough" in the direction of the + // keypress + nodeMid = _getMidpoint(nodes[i]); + switch (key) { + case PrefObserver['keyCodeLeft']: + if (nodeMid.x >= (currentlyFocusedMid.x - currentlyFocusedRect.width / 2)) { + continue; + } + break; + case PrefObserver['keyCodeRight']: + if (nodeMid.x <= (currentlyFocusedMid.x + currentlyFocusedRect.width / 2)) { + continue; + } + break; + + case PrefObserver['keyCodeUp']: + if (nodeMid.y >= (currentlyFocusedMid.y - currentlyFocusedRect.height / 2)) { + continue; + } + break; + case PrefObserver['keyCodeDown']: + if (nodeMid.y <= (currentlyFocusedMid.y + currentlyFocusedRect.height / 2)) { + continue; + } + break; + } + + // Initialize best to the first viable value: + if (!best) { + bestDist = _spatialDistanceOfCorner(currentlyFocused, nodes[i], key); + if (bestDist >= 0) { + best = nodes[i]; + } + continue; + } + + // Of the remaining nodes, pick the one closest to the currently focused + // node. + let curDist = _spatialDistanceOfCorner(currentlyFocused, nodes[i], key); + if ((curDist > bestDist) || curDist === -1) { + continue; + } + else if (curDist === bestDist) { + let midCurDist = _spatialDistance(currentlyFocused, nodes[i]); + let midBestDist = _spatialDistance(currentlyFocused, best); + if (midCurDist > midBestDist) + continue; + } + + best = nodes[i]; + bestDist = curDist; + } + return best; +} + +// Returns the midpoint of a node. +function _getMidpoint(node) { + let mid = {}; + let box = node.getBoundingClientRect(); + mid.x = box.left + (box.width / 2); + mid.y = box.top + (box.height / 2); + + return mid; +} + +// Returns true if the node is a type that we want to focus, false otherwise. +function _canFocus(node) { + if (node instanceof Ci.nsIDOMHTMLLinkElement || + node instanceof Ci.nsIDOMHTMLAnchorElement) { + return true; + } + if ((node instanceof Ci.nsIDOMHTMLButtonElement || + node instanceof Ci.nsIDOMHTMLInputElement || + node instanceof Ci.nsIDOMHTMLLinkElement || + node instanceof Ci.nsIDOMHTMLOptGroupElement || + node instanceof Ci.nsIDOMHTMLSelectElement || + node instanceof Ci.nsIDOMHTMLTextAreaElement) && + node.disabled === false) { + return true; + } + return false; +} + +// Returns a rectangle that extends to the end of the screen in the direction that +// the key is pressed. +function _getSearchRect(currentlyFocused, key, cssPageRect) { + let currentlyFocusedRect = currentlyFocused.getBoundingClientRect(); + + let newRect = {}; + newRect.left = currentlyFocusedRect.left; + newRect.top = currentlyFocusedRect.top; + newRect.right = currentlyFocusedRect.right; + newRect.bottom = currentlyFocusedRect.bottom; + newRect.width = currentlyFocusedRect.width; + newRect.height = currentlyFocusedRect.height; + + switch (key) { + case PrefObserver['keyCodeLeft']: + newRect.right = newRect.left; + newRect.left = cssPageRect.left; + newRect.width = newRect.right - newRect.left; + + newRect.bottom = cssPageRect.bottom; + newRect.top = cssPageRect.top; + newRect.height = newRect.bottom - newRect.top; + break; + + case PrefObserver['keyCodeRight']: + newRect.left = newRect.right; + newRect.right = cssPageRect.right; + newRect.width = newRect.right - newRect.left; + + newRect.bottom = cssPageRect.bottom; + newRect.top = cssPageRect.top; + newRect.height = newRect.bottom - newRect.top; + break; + + case PrefObserver['keyCodeUp']: + newRect.bottom = newRect.top; + newRect.top = cssPageRect.top; + newRect.height = newRect.bottom - newRect.top; + + newRect.right = cssPageRect.right; + newRect.left = cssPageRect.left; + newRect.width = newRect.right - newRect.left; + break; + + case PrefObserver['keyCodeDown']: + newRect.top = newRect.bottom; + newRect.bottom = cssPageRect.bottom; + newRect.height = newRect.bottom - newRect.top; + + newRect.right = cssPageRect.right; + newRect.left = cssPageRect.left; + newRect.width = newRect.right - newRect.left; + break; + } + return newRect; +} + +// Gets the distance between two points a and b. +function _spatialDistance(a, b) { + let mida = _getMidpoint(a); + let midb = _getMidpoint(b); + + return Math.round(Math.pow(mida.x - midb.x, 2) + + Math.pow(mida.y - midb.y, 2)); +} + +// Get the distance between the corner of two nodes +function _spatialDistanceOfCorner(from, to, key) { + let fromRect = from.getBoundingClientRect(); + let toRect = to.getBoundingClientRect(); + let fromMid = _getMidpoint(from); + let toMid = _getMidpoint(to); + let hDistance = 0; + let vDistance = 0; + + switch (key) { + case PrefObserver['keyCodeLeft']: + // Make sure the "to" node is really at the left side of "from" node by + // 1. Check the mid point + // 2. The right border of "to" node must be less than the "from" node + if ((fromMid.x - toMid.x) < 0 || toRect.right >= fromRect.right) + return -1; + hDistance = Math.abs(fromRect.left - toRect.right); + if (toRect.bottom <= fromRect.top) { + vDistance = fromRect.top - toRect.bottom; + } + else if (fromRect.bottom <= toRect.top) { + vDistance = toRect.top - fromRect.bottom; + } + else { + vDistance = 0; + } + break; + + case PrefObserver['keyCodeRight']: + if ((toMid.x - fromMid.x) < 0 || toRect.left <= fromRect.left) + return -1; + hDistance = Math.abs(toRect.left - fromRect.right); + if (toRect.bottom <= fromRect.top) { + vDistance = fromRect.top - toRect.bottom; + } + else if (fromRect.bottom <= toRect.top) { + vDistance = toRect.top - fromRect.bottom; + } + else { + vDistance = 0; + } + break; + + case PrefObserver['keyCodeUp']: + if ((fromMid.y - toMid.y) < 0 || toRect.bottom >= fromRect.bottom) + return -1; + vDistance = Math.abs(fromRect.top - toRect.bottom); + if (fromRect.right <= toRect.left) { + hDistance = toRect.left - fromRect.right; + } + else if (toRect.right <= fromRect.left) { + hDistance = fromRect.left - toRect.right; + } + else { + hDistance = 0; + } + break; + + case PrefObserver['keyCodeDown']: + if ((toMid.y - fromMid.y) < 0 || toRect.top <= fromRect.top) + return -1; + vDistance = Math.abs(toRect.top - fromRect.bottom); + if (fromRect.right <= toRect.left) { + hDistance = toRect.left - fromRect.right; + } + else if (toRect.right <= fromRect.left) { + hDistance = fromRect.left - toRect.right; + } + else { + hDistance = 0; + } + break; + } + return Math.round(Math.pow(hDistance, 2) + + Math.pow(vDistance, 2)); +} + +// Snav preference observer +var PrefObserver = { + register: function() { + this.prefService = Cc["@mozilla.org/preferences-service;1"] + .getService(Ci.nsIPrefService); + + this._branch = this.prefService.getBranch("snav."); + this._branch.QueryInterface(Ci.nsIPrefBranch2); + this._branch.addObserver("", this, false); + + // set current or default pref values + this.observe(null, "nsPref:changed", "enabled"); + this.observe(null, "nsPref:changed", "xulContentEnabled"); + this.observe(null, "nsPref:changed", "keyCode.modifier"); + this.observe(null, "nsPref:changed", "keyCode.right"); + this.observe(null, "nsPref:changed", "keyCode.up"); + this.observe(null, "nsPref:changed", "keyCode.down"); + this.observe(null, "nsPref:changed", "keyCode.left"); + this.observe(null, "nsPref:changed", "keyCode.return"); + }, + + observe: function(aSubject, aTopic, aData) { + if (aTopic != "nsPref:changed") { + return; + } + + // aSubject is the nsIPrefBranch we're observing (after appropriate QI) + // aData is the name of the pref that's been changed (relative to aSubject) + switch (aData) { + case "enabled": + try { + this.enabled = this._branch.getBoolPref("enabled"); + } catch (e) { + this.enabled = false; + } + break; + + case "xulContentEnabled": + try { + this.xulContentEnabled = this._branch.getBoolPref("xulContentEnabled"); + } catch (e) { + this.xulContentEnabled = false; + } + break; + + case "keyCode.modifier": { + let keyCodeModifier; + try { + keyCodeModifier = this._branch.getCharPref("keyCode.modifier"); + + // resetting modifiers + this.modifierAlt = false; + this.modifierShift = false; + this.modifierCtrl = false; + + if (keyCodeModifier != this.kNone) { + // we are using '+' as a separator in about:config. + let mods = keyCodeModifier.split(/\++/); + for (let i = 0; i < mods.length; i++) { + let mod = mods[i].toLowerCase(); + if (mod === "") + continue; + else if (mod == kAlt) + this.modifierAlt = true; + else if (mod == kShift) + this.modifierShift = true; + else if (mod == kCtrl) + this.modifierCtrl = true; + else { + keyCodeModifier = kNone; + break; + } + } + } + } catch (e) { } + break; + } + + case "keyCode.up": + try { + this.keyCodeUp = this._branch.getIntPref("keyCode.up"); + } catch (e) { + this.keyCodeUp = Ci.nsIDOMKeyEvent.DOM_VK_UP; + } + break; + case "keyCode.down": + try { + this.keyCodeDown = this._branch.getIntPref("keyCode.down"); + } catch (e) { + this.keyCodeDown = Ci.nsIDOMKeyEvent.DOM_VK_DOWN; + } + break; + case "keyCode.left": + try { + this.keyCodeLeft = this._branch.getIntPref("keyCode.left"); + } catch (e) { + this.keyCodeLeft = Ci.nsIDOMKeyEvent.DOM_VK_LEFT; + } + break; + case "keyCode.right": + try { + this.keyCodeRight = this._branch.getIntPref("keyCode.right"); + } catch (e) { + this.keyCodeRight = Ci.nsIDOMKeyEvent.DOM_VK_RIGHT; + } + break; + case "keyCode.return": + try { + this.keyCodeReturn = this._branch.getIntPref("keyCode.return"); + } catch (e) { + this.keyCodeReturn = Ci.nsIDOMKeyEvent.DOM_VK_RETURN; + } + break; + } + } +}; + +PrefObserver.register(); |