/*
* This implements link hinting. Typing "F" will enter link-hinting mode, where
* all clickable items on the page have a hint marker displayed containing a
* sequence of letters. Typing those letters will select a link.
*
* In our 'default' mode, the characters we use to show link hints are a
* user-configurable option. By default they're the home row. The CSS which is
* used on the link hints is also a configurable option.
*
* In 'filter' mode, our link hints are numbers, and the user can narrow down
* the range of possibilities by typing the text of the link itself.
*/
/*
* A set of common operations shared by any link-hinting system. Some methods
* are stubbed.
*/
var linkHintsBase = {
hintMarkers: [],
hintMarkerContainingDiv: null,
// The characters that were typed in while in "link hints" mode.
hintKeystrokeQueue: [],
modeActivated: false,
shouldOpenInNewTab: false,
shouldOpenWithQueue: false,
// Whether link hint's "open in current/new tab" setting is currently toggled
openLinkModeToggle: false,
// Whether we have added to the page the CSS needed to display link hints.
cssAdded: false,
/*
* To be called after linkHints has been generated from linkHintsBase.
*/
init: function() {
this.onKeyDownInMode = this.onKeyDownInMode.bind(this);
this.onKeyUpInMode = this.onKeyUpInMode.bind(this);
},
/*
* Generate an XPath describing what a clickable element is.
* The final expression will be something like "//button | //xhtml:button | ..."
*/
clickableElementsXPath: (function() {
var clickableElements = ["a", "textarea", "button", "select", "input[not(@type='hidden')]",
"*[@onclick or @tabindex or @role='link' or @role='button']"];
var xpath = [];
for (var i in clickableElements)
xpath.push("//" + clickableElements[i], "//xhtml:" + clickableElements[i]);
return xpath.join(" | ")
})(),
// We need this as a top-level function because our command system doesn't yet support arguments.
activateModeToOpenInNewTab: function() { this.activateMode(true, false); },
activateModeWithQueue: function() { this.activateMode(true, true); },
activateMode: function (openInNewTab, withQueue) {
if (!this.cssAdded)
addCssToPage(linkHintCss); // linkHintCss is declared by vimiumFrontend.js
this.linkHintCssAdded = true;
this.modeActivated = true;
this.setOpenLinkMode(openInNewTab, withQueue);
this.buildLinkHints();
document.addEventListener("keydown", this.onKeyDownInMode, true);
document.addEventListener("keyup", this.onKeyUpInMode, true);
},
setOpenLinkMode: function(openInNewTab, withQueue) {
this.shouldOpenInNewTab = openInNewTab;
this.shouldOpenWithQueue = withQueue;
if (this.shouldOpenWithQueue) {
HUD.show("Open multiple links in a new tab");
} else {
if (this.shouldOpenInNewTab)
HUD.show("Open link in new tab");
else
HUD.show("Open link in current tab");
}
},
/*
* Builds and displays link hints for every visible clickable item on the page.
*/
buildLinkHints: function() {
var visibleElements = this.getVisibleClickableElements();
// Initialize the number used to generate the character hints to be as many digits as we need to
// highlight all the links on the page; we don't want some link hints to have more chars than others.
var linkHintNumber = 0;
this.initSetMarkerAttributes(visibleElements);
for (var i = 0; i < visibleElements.length; i++) {
this.hintMarkers.push(this.createMarkerFor(visibleElements[i], linkHintNumber));
linkHintNumber++;
}
// Note(philc): Append these markers as top level children instead of as child nodes to the link itself,
// because some clickable elements cannot contain children, e.g. submit buttons. This has the caveat
// that if you scroll the page and the link has position=fixed, the marker will not stay fixed.
// Also note that adding these nodes to document.body all at once is significantly faster than one-by-one.
this.hintMarkerContainingDiv = document.createElement("div");
this.hintMarkerContainingDiv.className = "internalVimiumHintMarker";
for (var i = 0; i < this.hintMarkers.length; i++)
this.hintMarkerContainingDiv.appendChild(this.hintMarkers[i]);
// sometimes this is triggered before documentElement is created
// TODO(int3): fail more gracefully?
if (document.documentElement)
document.documentElement.appendChild(this.hintMarkerContainingDiv);
else
this.deactivateMode();
},
/*
* Sets the data attributes of the marker. Does not need to handle styling
* and positioning. MUST set the hintString and innerHTML properties.
*/
setMarkerAttributes: function(marker, linkHintNumber) {},
/*
* A hook for any necessary initialization for setMarkerAttributes. Takes an
* array of visible elements. Any return value is ignored.
*/
initSetMarkerAttributes: function(visibleElements) {
},
/*
* Returns all clickable elements that are not hidden and are in the current viewport.
* We prune invisible elements partly for performance reasons, but moreso it's to decrease the number
* of digits needed to enumerate all of the links on screen.
*/
getVisibleClickableElements: function() {
var resultSet = document.evaluate(this.clickableElementsXPath, document.body,
function (namespace) {
return namespace == "xhtml" ? "http://www.w3.org/1999/xhtml" : null;
},
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
var visibleElements = [];
// Find all visible clickable elements.
for (var i = 0; i < resultSet.snapshotLength; i++) {
var element = resultSet.snapshotItem(i);
var clientRect = element.getClientRects()[0];
if (this.isVisible(element, clientRect))
visibleElements.push({element: element, rect: clientRect});
// If the link has zero dimensions, it may be wrapping visible
// but floated elements. Check for this.
if (clientRect && (clientRect.width == 0 || clientRect.height == 0)) {
for (var j = 0; j < element.children.length; j++) {
if (window.getComputedStyle(element.children[j], null).getPropertyValue('float') == 'none')
continue;
var childClientRect = element.children[j].getClientRects()[0];
if (!this.isVisible(element.children[j], childClientRect))
continue;
visibleElements.push({element: element.children[j], rect: childClientRect});
break;
}
}
}
return visibleElements;
},
/*
* Returns true if element is visible.
*/
isVisible: function(element, clientRect) {
// Exclude links which have just a few pixels on screen, because the link hints won't show for them anyway.
var zoomFactor = currentZoomLevel / 100.0;
if (!clientRect || clientRect.top < 0 || clientRect.top * zoomFactor >= window.innerHeight - 4 ||
clientRect.left < 0 || clientRect.left * zoomFactor >= window.innerWidth - 4)
return false;
if (clientRect.width < 3 || clientRect.height < 3)
return false;
// eliminate invisible elements (see test_harnesses/visibility_test.html)
var computedStyle = window.getComputedStyle(element, null);
if (computedStyle.getPropertyValue('visibility') != 'visible' ||
computedStyle.getPropertyValue('display') == 'none')
return false;
return true;
},
/*
* Handles shift and esc keys. The other keys are passed to normalKeyDownHandler.
*/
onKeyDownInMode: function(event) {
if (event.keyCode == keyCodes.shiftKey && !this.openLinkModeToggle) {
// Toggle whether to open link in a new or current tab.
this.setOpenLinkMode(!this.shouldOpenInNewTab, this.shouldOpenWithQueue);
this.openLinkModeToggle = true;
}
// TODO(philc): Ignore keys that have modifiers.
if (isEscape(event)) {
this.deactivateMode();
} else {
this.normalKeyDownHandler(event);
}
event.stopPropagation();
event.preventDefault();
},
/*
* Handle all keys other than shift and esc. Return value is ignored.
*/
normalKeyDownHandler: function(event) {},
onKeyUpInMode: function(event) {
if (event.keyCode == keyCodes.shiftKey && this.openLinkModeToggle) {
// Revert toggle on whether to open link in new or current tab.
this.setOpenLinkMode(!this.shouldOpenInNewTab, this.shouldOpenWithQueue);
this.openLinkModeToggle = false;
}
event.stopPropagation();
event.preventDefault();
},
/*
* When only one link hint remains, this function activates it in the appropriate way.
*/
activateLink: function(matchedLink) {
if (this.isSelectable(matchedLink)) {
this.simulateSelect(matchedLink);
this.deactivateMode();
} else {
// When we're opening the link in the current tab, don't navigate to the selected link immediately;
// we want to give the user some feedback depicting which link they've selected by focusing it.
if (this.shouldOpenWithQueue) {
this.simulateClick(matchedLink);
this.resetMode();
} else if (this.shouldOpenInNewTab) {
this.simulateClick(matchedLink);
matchedLink.focus();
this.deactivateMode();
} else {
setTimeout(this.simulateClick.bind(this, matchedLink), 400);
matchedLink.focus();
this.deactivateMode();
}
}
},
/*
* Selectable means the element has a text caret; this is not the same as "focusable".
*/
isSelectable: function(element) {
var selectableTypes = ["search", "text", "password"];
return (element.nodeName.toLowerCase() == "input" && selectableTypes.indexOf(element.type) >= 0) ||
element.nodeName.toLowerCase() == "textarea";
},
simulateSelect: function(element) {
element.focus();
// When focusing a textbox, put the selection caret at the end of the textbox's contents.
element.setSelectionRange(element.value.length, element.value.length);
},
/*
* Hides linkMarker if it does not match testString, and shows linkMarker
* if it does match but was previously hidden. To be used with Array.filter().
*/
toggleHighlights: function(testString, linkMarker) {
if (linkMarker.getAttribute("hintString").indexOf(testString) == 0) {
if (linkMarker.style.display == "none")
linkMarker.style.display = "";
for (var j = 0; j < linkMarker.childNodes.length; j++)
linkMarker.childNodes[j].className = (j >= testString.length) ? "" : "matchingCharacter";
return true;
} else {
linkMarker.style.display = "none";
return false;
}
},
simulateClick: function(link) {
var event = document.createEvent("MouseEvents");
// When "clicking" on a link, dispatch the event with the appropriate meta key (CMD on Mac, CTRL on windows)
// to open it in a new tab if necessary.
var metaKey = (platform == "Mac" && linkHints.shouldOpenInNewTab);
var ctrlKey = (platform != "Mac" && linkHints.shouldOpenInNewTab);
event.initMouseEvent("click", true, true, window, 1, 0, 0, 0, 0, ctrlKey, false, false, metaKey, 0, null);
// Debugging note: Firefox will not execute the link's default action if we dispatch this click event,
// but Webkit will. Dispatching a click on an input box does not seem to focus it; we do that separately
link.dispatchEvent(event);
},
deactivateMode: function() {
if (this.hintMarkerContainingDiv)
this.hintMarkerContainingDiv.parentNode.removeChild(this.hintMarkerContainingDiv);
this.hintMarkerContainingDiv = null;
this.hintMarkers = [];
this.hintKeystrokeQueue = [];
document.removeEventListener("keydown", this.onKeyDownInMode, true);
document.removeEventListener("keyup", this.onKeyUpInMode, true);
this.modeActivated = false;
HUD.hide();
},
resetMode: function() {
this.deactivateMode();
this.activateModeWithQueue();
},
/*
* Creates a link marker for the given link.
*/
createMarkerFor: function(link, linkHintNumber) {
var marker = document.createElement("div");
marker.className = "internalVimiumHintMarker vimiumHintMarker";
marker.clickableItem = link.element;
this.setMarkerAttributes(marker, linkHintNumber);
// Note: this call will be expensive if we modify the DOM in between calls.
var clientRect = link.rect;
// The coordinates given by the window do not have the zoom factor included since the zoom is set only on
// the document node.
var zoomFactor = currentZoomLevel / 100.0;
marker.style.left = clientRect.left + window.scrollX / zoomFactor + "px";
marker.style.top = clientRect.top + window.scrollY / zoomFactor + "px";
return marker;
},
/*
* Make each hint character a span, so that we can highlight the typed characters as you type them.
*/
spanWrap: function(hintString) {
var innerHTML = [];
for (var i = 0; i < hintString.length; i++)
innerHTML.push("" + hintString[i].toUpperCase() + "");
return innerHTML.join("");
},
};
var linkHints;
/*
* Create the instance of linkHints, specialized based on the user settings.
*/
function initializeLinkHints() {
if (settings.get('filterLinkHints') != "true") { // the default hinting system
linkHints = utils.extendWithSuper(linkHintsBase, {
digitsNeeded: 1,
logXOfBase: function(x, base) { return Math.log(x) / Math.log(base); },
initSetMarkerAttributes: function(visibleElements) {
this.digitsNeeded = Math.ceil(this.logXOfBase(
visibleElements.length, settings.get('linkHintCharacters').length));
},
setMarkerAttributes: function(marker, linkHintNumber) {
var hintString = this.numberToHintString(linkHintNumber, this.digitsNeeded);
marker.innerHTML = this.spanWrap(hintString);
marker.setAttribute("hintString", hintString);
return marker;
},
/*
* Converts a number like "8" into a hint string like "JK". This is used to sequentially generate all of
* the hint text. The hint string will be "padded with zeroes" to ensure its length is equal to numHintDigits.
*/
numberToHintString: function(number, numHintDigits) {
var base = settings.get('linkHintCharacters').length;
var hintString = [];
var remainder = 0;
do {
remainder = number % base;
hintString.unshift(settings.get('linkHintCharacters')[remainder]);
number -= remainder;
number /= Math.floor(base);
} while (number > 0);
// Pad the hint string we're returning so that it matches numHintDigits.
var hintStringLength = hintString.length;
for (var i = 0; i < numHintDigits - hintStringLength; i++)
hintString.unshift(settings.get('linkHintCharacters')[0]);
return hintString.join("");
},
normalKeyDownHandler: function (event) {
var keyChar = getKeyChar(event);
if (!keyChar)
return;
if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) {
if (this.hintKeystrokeQueue.length == 0) {
this.deactivateMode();
} else {
this.hintKeystrokeQueue.pop();
var matchString = this.hintKeystrokeQueue.join("");
this.hintMarkers.filter(this.toggleHighlights.bind(this, matchString));
}
} else if (settings.get('linkHintCharacters').indexOf(keyChar) >= 0) {
this.hintKeystrokeQueue.push(keyChar);
var matchString = this.hintKeystrokeQueue.join("");
linksMatched = this.hintMarkers.filter(this.toggleHighlights.bind(this, matchString));
if (linksMatched.length == 0)
this.deactivateMode();
else if (linksMatched.length == 1)
this.activateLink(linksMatched[0].clickableItem);
}
}
});
} else {
linkHints = utils.extendWithSuper(linkHintsBase, {
linkTextKeystrokeQueue: [],
labelMap: {},
delayMode: false,
/*
* Generate a map of input element => label
*/
initSetMarkerAttributes: function() {
var labels = document.querySelectorAll("label");
for (var i = 0; i < labels.length; i++) {
var forElement = labels[i].getAttribute("for");
if (forElement) {
var labelText = labels[i].textContent.trim();
// remove trailing : commonly found in labels
if (labelText[labelText.length-1] == ":")
labelText = labelText.substr(0, labelText.length-1);
this.labelMap[forElement] = labelText;
}
}
},
setMarkerAttributes: function(marker, linkHintNumber) {
var hintString = (linkHintNumber + 1).toString();
var linkText = "";
var showLinkText = false;
var element = marker.clickableItem;
// toLowerCase is necessary as html documents return 'IMG'
// and xhtml documents return 'img'
var nodeName = element.nodeName.toLowerCase();
if (nodeName == "input") {
if (this.labelMap[element.id]) {
linkText = this.labelMap[element.id];
showLinkText = true;
} else if (element.type != "password") {
linkText = element.value;
}
// check if there is an image embedded in the tag
} else if (nodeName == "a" && !element.textContent.trim()
&& element.firstElementChild
&& element.firstElementChild.nodeName.toLowerCase() == "img") {
linkText = element.firstElementChild.alt || element.firstElementChild.title;
if (linkText)
showLinkText = true;
} else {
linkText = element.textContent || element.innerHTML;
}
linkText = linkText.trim().toLowerCase();
marker.setAttribute("hintString", hintString);
marker.innerHTML = this.spanWrap(hintString
+ (showLinkText ? ": " + linkText : ""));
marker.setAttribute("linkText", linkText);
},
normalKeyDownHandler: function(event) {
if (this.delayMode)
return;
if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) {
if (this.linkTextKeystrokeQueue.length == 0 && this.hintKeystrokeQueue.length == 0) {
this.deactivateMode();
} else {
// backspace clears hint key queue first, then acts on link text key queue
if (this.hintKeystrokeQueue.pop())
this.filterLinkHints();
else {
this.linkTextKeystrokeQueue.pop();
this.filterLinkHints();
}
}
} else if (event.keyCode == keyCodes.enter) {
// activate the lowest-numbered link hint that is visible
for (var i = 0; i < this.hintMarkers.length; i++)
if (this.hintMarkers[i].style.display != 'none') {
this.activateLink(this.hintMarkers[i].clickableItem);
break;
}
} else {
var keyChar = getKeyChar(event);
if (!keyChar)
return;
var linksMatched, matchString;
if (/[0-9]/.test(keyChar)) {
this.hintKeystrokeQueue.push(keyChar);
matchString = this.hintKeystrokeQueue.join("");
linksMatched = this.hintMarkers.filter((function(linkMarker) {
if (linkMarker.getAttribute('filtered') == 'true')
return false;
return this.toggleHighlights(matchString, linkMarker);
}).bind(this));
} else {
// since we might renumber the hints, the current hintKeyStrokeQueue
// should be rendered invalid (i.e. reset).
this.hintKeystrokeQueue = [];
this.linkTextKeystrokeQueue.push(keyChar);
matchString = this.linkTextKeystrokeQueue.join("");
linksMatched = this.filterLinkHints(matchString);
}
if (linksMatched.length == 0)
this.deactivateMode();
else if (linksMatched.length == 1) {
if (/[0-9]/.test(keyChar)) {
this.activateLink(linksMatched[0].clickableItem);
} else {
// In filter mode, people tend to type out words past the point
// needed for a unique match. Hence we should avoid passing
// control back to command mode immediately after a match is found.
this.activateLink(linksMatched[0].clickableItem, 200);
}
}
}
},
/*
* If called without arguments, it executes immediately. Othewise, it
* executes after 'delay'.
*/
activateLink: function(matchedLink, delay) {
var that = this;
if (delay) {
that.delayMode = true;
if (that.isSelectable(matchedLink)) {
that.simulateSelect(matchedLink);
that.deactivateMode(delay, function() { that.delayMode = false; });
} else {
if (that.shouldOpenWithQueue) {
that.simulateClick(matchedLink);
that.resetMode(delay);
} else if (that.shouldOpenInNewTab) {
that.simulateClick(matchedLink);
matchedLink.focus();
that.deactivateMode(delay, function() { that.delayMode = false; });
} else {
setTimeout(that.simulateClick.bind(that, matchedLink), 400);
matchedLink.focus();
that.deactivateMode(delay, function() { that.delayMode = false; });
}
}
} else {
that._super('activateLink')(matchedLink);
}
},
/*
* Hides the links that do not match the linkText search string and marks
* them with the 'filtered' DOM property. Renumbers the remainder. Should
* only be called when there is a change in linkTextKeystrokeQueue, to
* avoid undesired renumbering.
*/
filterLinkHints: function(searchString) {
var linksMatched = [];
var linkSearchString = this.linkTextKeystrokeQueue.join("");
for (var i = 0; i < this.hintMarkers.length; i++) {
var linkMarker = this.hintMarkers[i];
var matchedLink = linkMarker.getAttribute("linkText").toLowerCase().indexOf(linkSearchString.toLowerCase()) >= 0;
if (!matchedLink) {
linkMarker.style.display = "none";
linkMarker.setAttribute("filtered", "true");
} else {
if (linkMarker.style.display == "none")
linkMarker.style.display = "";
this.setMarkerAttributes(linkMarker, linksMatched.length);
linkMarker.setAttribute("filtered", "false");
linksMatched.push(linkMarker);
}
}
return linksMatched;
},
/*
* If called without arguments, it executes immediately. Othewise, it
* executes after 'delay' and invokes 'callback' when it is finished.
*/
deactivateMode: function(delay, callback) {
var that = this;
function deactivate() {
that.linkTextKeystrokeQueue = [];
that.labelMap = {};
that._super('deactivateMode')();
}
if (!delay)
deactivate();
else
setTimeout(function() { deactivate(); if (callback) callback(); }, delay);
},
resetMode: function(delay, callback) {
var that = this;
if (!delay) {
that.deactivateMode();
that.activateModeWithQueue();
} else {
that.deactivateMode(delay, function() {
that.delayMode = false;
that.activateModeWithQueue();
if (callback)
callback();
});
}
}
});
}
linkHints.init();
}