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