aboutsummaryrefslogtreecommitdiffstats
path: root/content_scripts/link_hints.js
diff options
context:
space:
mode:
Diffstat (limited to 'content_scripts/link_hints.js')
-rw-r--r--content_scripts/link_hints.js552
1 files changed, 552 insertions, 0 deletions
diff --git a/content_scripts/link_hints.js b/content_scripts/link_hints.js
new file mode 100644
index 00000000..7d6b431f
--- /dev/null
+++ b/content_scripts/link_hints.js
@@ -0,0 +1,552 @@
+/*
+ * 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.
+ */
+var linkHints = {
+ hintMarkers: [],
+ hintMarkerContainingDiv: null,
+ shouldOpenInNewTab: false,
+ shouldOpenWithQueue: false,
+ // function that does the appropriate action on the selected link
+ linkActivator: undefined,
+ // While in delayMode, all keypresses have no effect.
+ delayMode: false,
+ // Handle the link hinting marker generation and matching. Must be initialized after settings have been
+ // loaded, so that we can retrieve the option setting.
+ markerMatcher: undefined,
+
+ /*
+ * To be called after linkHints has been generated from linkHintsBase.
+ */
+ init: function() {
+ this.onKeyDownInMode = this.onKeyDownInMode.bind(this);
+ this.markerMatcher = settings.get('filterLinkHints') ? filterHints : alphabetHints;
+ },
+
+ /*
+ * Generate an XPath describing what a clickable element is.
+ * The final expression will be something like "//button | //xhtml:button | ..."
+ * We use translate() instead of lower-case() because Chrome only supports XPath 1.0.
+ */
+ clickableElementsXPath: domUtils.makeXPath(["a", "area[@href]", "textarea", "button", "select",
+ "input[not(@type='hidden' or @disabled or @readonly)]",
+ "*[@onclick or @tabindex or @role='link' or @role='button' or contains(@class, 'button') or " +
+ "@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"]),
+
+ // We need this as a top-level function because our command system doesn't yet support arguments.
+ activateModeToOpenInNewTab: function() { this.activateMode(true, false, false); },
+
+ activateModeToCopyLinkUrl: function() { this.activateMode(null, false, true); },
+
+ activateModeWithQueue: function() { this.activateMode(true, true, false); },
+
+ activateMode: function(openInNewTab, withQueue, copyLinkUrl) {
+ if (!document.getElementById('vimiumLinkHintCss'))
+ // linkHintCss is declared by vimiumFrontend.js and contains the user supplied css overrides.
+ addCssToPage(linkHintCss, 'vimiumLinkHintCss');
+ this.setOpenLinkMode(openInNewTab, withQueue, copyLinkUrl);
+ this.buildLinkHints();
+ handlerStack.push({ // handlerStack is declared by vimiumFrontend.js
+ keydown: this.onKeyDownInMode,
+ // trap all key events
+ keypress: function() { return false; },
+ keyup: function() { return false; }
+ });
+ },
+
+ setOpenLinkMode: function(openInNewTab, withQueue, copyLinkUrl) {
+ this.shouldOpenInNewTab = openInNewTab;
+ this.shouldOpenWithQueue = withQueue;
+
+ if (openInNewTab || withQueue) {
+ if (openInNewTab)
+ HUD.show("Open link in new tab");
+ else if (withQueue)
+ HUD.show("Open multiple links in a new tab");
+ this.linkActivator = function(link) {
+ // 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.
+ domUtils.simulateClick(link, { metaKey: platform == "Mac", ctrlKey: platform != "Mac" });
+ }
+ } else if (copyLinkUrl) {
+ HUD.show("Copy link URL to Clipboard");
+ this.linkActivator = function(link) {
+ chrome.extension.sendRequest({handler: 'copyToClipboard', data: link.href});
+ }
+ } else {
+ HUD.show("Open link in current tab");
+ // 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 time to notice which link has received focus.
+ this.linkActivator = function(link) {
+ setTimeout(domUtils.simulateClick.bind(domUtils, link), 400);
+ }
+ }
+ },
+
+ /*
+ * Builds and displays link hints for every visible clickable item on the page.
+ */
+ buildLinkHints: function() {
+ var visibleElements = this.getVisibleClickableElements();
+ this.hintMarkers = this.markerMatcher.getHintMarkers(visibleElements);
+
+ // 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.id = "vimiumHintMarkerContainer";
+ this.hintMarkerContainingDiv.className = "vimiumReset";
+ 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();
+ },
+
+ /*
+ * 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 = domUtils.evaluateXPath(this.clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);
+
+ var visibleElements = [];
+
+ // Find all visible clickable elements.
+ for (var i = 0, count = resultSet.snapshotLength; i < count; i++) {
+ var element = resultSet.snapshotItem(i);
+ var clientRect = domUtils.getVisibleClientRect(element, clientRect);
+ if (clientRect !== null)
+ visibleElements.push({element: element, rect: clientRect});
+
+ if (element.localName === "area") {
+ var map = element.parentElement;
+ if (!map) continue;
+ var img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']");
+ if (!img) continue;
+ var imgClientRects = img.getClientRects();
+ if (imgClientRects.length == 0) continue;
+ var c = element.coords.split(/,/);
+ var coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)];
+ var rect = {
+ top: imgClientRects[0].top + coords[1],
+ left: imgClientRects[0].left + coords[0],
+ right: imgClientRects[0].left + coords[2],
+ bottom: imgClientRects[0].top + coords[3],
+ width: coords[2] - coords[0],
+ height: coords[3] - coords[1]
+ };
+
+ visibleElements.push({element: element, rect: rect});
+ }
+ }
+ return visibleElements;
+ },
+
+ /*
+ * Handles shift and esc keys. The other keys are passed to markerMatcher.matchHintsByKey.
+ */
+ onKeyDownInMode: function(event) {
+ if (this.delayMode)
+ return;
+
+ if (event.keyCode == keyCodes.shiftKey && this.shouldOpenInNewTab !== null) {
+ // Toggle whether to open link in a new or current tab.
+ this.setOpenLinkMode(!this.shouldOpenInNewTab, this.shouldOpenWithQueue, false);
+ handlerStack.push({
+ keyup: function(event) {
+ if (event.keyCode !== keyCodes.shiftKey) return;
+ linkHints.setOpenLinkMode(!linkHints.shouldOpenInNewTab, linkHints.shouldOpenWithQueue, false);
+ handlerStack.pop();
+ }
+ });
+ }
+
+ // TODO(philc): Ignore keys that have modifiers.
+ if (isEscape(event)) {
+ this.deactivateMode();
+ } else {
+ var keyResult = this.markerMatcher.matchHintsByKey(event, this.hintMarkers);
+ var linksMatched = keyResult.linksMatched;
+ var delay = keyResult.delay !== undefined ? keyResult.delay : 0;
+ if (linksMatched.length == 0) {
+ this.deactivateMode();
+ } else if (linksMatched.length == 1) {
+ this.activateLink(linksMatched[0], delay);
+ } else {
+ for (var i in this.hintMarkers)
+ this.hideMarker(this.hintMarkers[i]);
+ for (var i in linksMatched)
+ this.showMarker(linksMatched[i], this.markerMatcher.hintKeystrokeQueue.length);
+ }
+ }
+ },
+
+ /*
+ * When only one link hint remains, this function activates it in the appropriate way.
+ */
+ activateLink: function(matchedLink, delay) {
+ this.delayMode = true;
+ var clickEl = matchedLink.clickableItem;
+ if (domUtils.isSelectable(clickEl)) {
+ domUtils.simulateSelect(clickEl);
+ this.deactivateMode(delay, function() { linkHints.delayMode = false; });
+ } else {
+ // TODO figure out which other input elements should not receive focus
+ if (clickEl.nodeName.toLowerCase() === 'input' && clickEl.type !== 'button')
+ clickEl.focus();
+ domUtils.flashRect(matchedLink.rect);
+ this.linkActivator(clickEl);
+ if (this.shouldOpenWithQueue) {
+ this.deactivateMode(delay, function() {
+ linkHints.delayMode = false;
+ linkHints.activateModeWithQueue();
+ });
+ } else {
+ this.deactivateMode(delay, function() { linkHints.delayMode = false; });
+ }
+ }
+ },
+
+ /*
+ * Shows the marker, highlighting matchingCharCount characters.
+ */
+ showMarker: function(linkMarker, matchingCharCount) {
+ linkMarker.style.display = "";
+ for (var j = 0, count = linkMarker.childNodes.length; j < count; j++)
+ (j < matchingCharCount) ? linkMarker.childNodes[j].classList.add("matchingCharacter") :
+ linkMarker.childNodes[j].classList.remove("matchingCharacter");
+ },
+
+ hideMarker: function(linkMarker) {
+ linkMarker.style.display = "none";
+ },
+
+ /*
+ * If called without arguments, it executes immediately. Othewise, it
+ * executes after 'delay' and invokes 'callback' when it is finished.
+ */
+ deactivateMode: function(delay, callback) {
+ function deactivate() {
+ if (linkHints.markerMatcher.deactivate)
+ linkHints.markerMatcher.deactivate();
+ if (linkHints.hintMarkerContainingDiv)
+ linkHints.hintMarkerContainingDiv.parentNode.removeChild(linkHints.hintMarkerContainingDiv);
+ linkHints.hintMarkerContainingDiv = null;
+ linkHints.hintMarkers = [];
+ handlerStack.pop();
+ HUD.hide();
+ }
+ // we invoke the deactivate() function directly instead of using setTimeout(callback, 0) so that
+ // deactivateMode can be tested synchronously
+ if (!delay) {
+ deactivate();
+ if (callback) callback();
+ } else {
+ setTimeout(function() { deactivate(); if (callback) callback(); }, delay);
+ }
+ },
+
+};
+
+var alphabetHints = {
+ hintKeystrokeQueue: [],
+ logXOfBase: function(x, base) { return Math.log(x) / Math.log(base); },
+
+ getHintMarkers: function(visibleElements) {
+ var hintStrings = this.hintStrings(visibleElements.length);
+ var hintMarkers = [];
+ for (var i = 0, count = visibleElements.length; i < count; i++) {
+ var marker = hintUtils.createMarkerFor(visibleElements[i]);
+ marker.hintString = hintStrings[i];
+ marker.innerHTML = hintUtils.spanWrap(marker.hintString.toUpperCase());
+ hintMarkers.push(marker);
+ }
+
+ return hintMarkers;
+ },
+
+ /*
+ * Returns a list of hint strings which will uniquely identify the given number of links. The hint strings
+ * may be of different lengths.
+ */
+ hintStrings: function(linkCount) {
+ var linkHintCharacters = settings.get("linkHintCharacters");
+ // Determine how many digits the link hints will require in the worst case. Usually we do not need
+ // all of these digits for every link single hint, so we can show shorter hints for a few of the links.
+ var digitsNeeded = Math.ceil(this.logXOfBase(linkCount, linkHintCharacters.length));
+ // Short hints are the number of hints we can possibly show which are (digitsNeeded - 1) digits in length.
+ var shortHintCount = Math.floor(
+ (Math.pow(linkHintCharacters.length, digitsNeeded) - linkCount) /
+ linkHintCharacters.length);
+ var longHintCount = linkCount - shortHintCount;
+
+ var hintStrings = [];
+
+ if (digitsNeeded > 1)
+ for (var i = 0; i < shortHintCount; i++)
+ hintStrings.push(this.numberToHintString(i, digitsNeeded - 1, linkHintCharacters));
+
+ var start = shortHintCount * linkHintCharacters.length;
+ for (var i = start; i < start + longHintCount; i++)
+ hintStrings.push(this.numberToHintString(i, digitsNeeded, linkHintCharacters));
+
+ return this.shuffleHints(hintStrings, linkHintCharacters.length);
+ },
+
+ /*
+ * This shuffles the given set of hints so that they're scattered -- hints starting with the same character
+ * will be spread evenly throughout the array.
+ */
+ shuffleHints: function(hints, characterSetLength) {
+ var buckets = [], i = 0;
+ for (i = 0; i < characterSetLength; i++)
+ buckets[i] = []
+ for (i = 0; i < hints.length; i++)
+ buckets[i % buckets.length].push(hints[i]);
+ var result = [];
+ for (i = 0; i < buckets.length; i++)
+ result = result.concat(buckets[i]);
+ return result;
+ },
+
+ /*
+ * 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, characterSet) {
+ var base = characterSet.length;
+ var hintString = [];
+ var remainder = 0;
+ do {
+ remainder = number % base;
+ hintString.unshift(characterSet[remainder]);
+ number -= remainder;
+ number /= Math.floor(base);
+ } while (number > 0);
+
+ // Pad the hint string we're returning so that it matches numHintDigits.
+ // Note: the loop body changes hintString.length, so the original length must be cached!
+ var hintStringLength = hintString.length;
+ for (var i = 0; i < numHintDigits - hintStringLength; i++)
+ hintString.unshift(characterSet[0]);
+
+ return hintString.join("");
+ },
+
+ matchHintsByKey: function(event, hintMarkers) {
+ var keyChar = getKeyChar(event);
+
+ if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) {
+ if (!this.hintKeystrokeQueue.pop())
+ return { linksMatched: [] };
+ } else if (keyChar && settings.get('linkHintCharacters').indexOf(keyChar) >= 0) {
+ this.hintKeystrokeQueue.push(keyChar);
+ }
+
+ var matchString = this.hintKeystrokeQueue.join("");
+ var linksMatched = hintMarkers.filter(function(linkMarker) {
+ return linkMarker.hintString.indexOf(matchString) == 0;
+ });
+ return { linksMatched: linksMatched };
+ },
+
+ deactivate: function() {
+ this.hintKeystrokeQueue = [];
+ }
+
+};
+
+var filterHints = {
+ hintKeystrokeQueue: [],
+ linkTextKeystrokeQueue: [],
+ labelMap: {},
+
+ /*
+ * Generate a map of input element => label
+ */
+ generateLabelMap: function() {
+ var labels = document.querySelectorAll("label");
+ for (var i = 0, count = labels.length; i < count; 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;
+ }
+ }
+ },
+
+ generateHintString: function(linkHintNumber) {
+ return (linkHintNumber + 1).toString();
+ },
+
+ generateLinkText: function(element) {
+ var linkText = "";
+ var showLinkText = false;
+ // 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 <a> 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;
+ }
+ return { text: linkText, show: showLinkText };
+ },
+
+ renderMarker: function(marker) {
+ marker.innerHTML = hintUtils.spanWrap(marker.hintString +
+ (marker.showLinkText ? ": " + marker.linkText : ""));
+ },
+
+ getHintMarkers: function(visibleElements) {
+ this.generateLabelMap();
+ var hintMarkers = [];
+ for (var i = 0, count = visibleElements.length; i < count; i++) {
+ var marker = hintUtils.createMarkerFor(visibleElements[i]);
+ marker.hintString = this.generateHintString(i);
+ var linkTextObject = this.generateLinkText(marker.clickableItem);
+ marker.linkText = linkTextObject.text;
+ marker.showLinkText = linkTextObject.show;
+ this.renderMarker(marker);
+ hintMarkers.push(marker);
+ }
+ return hintMarkers;
+ },
+
+ matchHintsByKey: function(event, hintMarkers) {
+ var keyChar = getKeyChar(event);
+ var delay = 0;
+ var userIsTypingLinkText = false;
+
+ if (event.keyCode == keyCodes.enter) {
+ // activate the lowest-numbered link hint that is visible
+ for (var i = 0, count = hintMarkers.length; i < count; i++)
+ if (hintMarkers[i].style.display != 'none') {
+ return { linksMatched: [ hintMarkers[i] ] };
+ }
+ } else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) {
+ // backspace clears hint key queue first, then acts on link text key queue.
+ // if both queues are empty. exit hinting mode
+ if (!this.hintKeystrokeQueue.pop() && !this.linkTextKeystrokeQueue.pop())
+ return { linksMatched: [] };
+ } else if (keyChar) {
+ if (/[0-9]/.test(keyChar))
+ this.hintKeystrokeQueue.push(keyChar);
+ else {
+ // since we might renumber the hints, the current hintKeyStrokeQueue
+ // should be rendered invalid (i.e. reset).
+ this.hintKeystrokeQueue = [];
+ this.linkTextKeystrokeQueue.push(keyChar);
+ userIsTypingLinkText = true;
+ }
+ }
+
+ // at this point, linkTextKeystrokeQueue and hintKeystrokeQueue have been updated to reflect the latest
+ // input. use them to filter the link hints accordingly.
+ var linksMatched = this.filterLinkHints(hintMarkers);
+ var matchString = this.hintKeystrokeQueue.join("");
+ linksMatched = linksMatched.filter(function(linkMarker) {
+ return !linkMarker.filtered && linkMarker.hintString.indexOf(matchString) == 0;
+ });
+
+ if (linksMatched.length == 1 && userIsTypingLinkText) {
+ // 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.
+ var delay = 200;
+ }
+
+ return { linksMatched: linksMatched, delay: delay };
+ },
+
+ /*
+ * Marks the links that do not match the linkText search string with the 'filtered' DOM property. Renumbers
+ * the remainder if necessary.
+ */
+ filterLinkHints: function(hintMarkers) {
+ var linksMatched = [];
+ var linkSearchString = this.linkTextKeystrokeQueue.join("");
+
+ for (var i = 0; i < hintMarkers.length; i++) {
+ var linkMarker = hintMarkers[i];
+ var matchedLink = linkMarker.linkText.toLowerCase().indexOf(linkSearchString.toLowerCase()) >= 0;
+
+ if (!matchedLink) {
+ linkMarker.filtered = true;
+ } else {
+ linkMarker.filtered = false;
+ var oldHintString = linkMarker.hintString;
+ linkMarker.hintString = this.generateHintString(linksMatched.length);
+ if (linkMarker.hintString != oldHintString)
+ this.renderMarker(linkMarker);
+ linksMatched.push(linkMarker);
+ }
+ }
+ return linksMatched;
+ },
+
+ deactivate: function(delay, callback) {
+ this.hintKeystrokeQueue = [];
+ this.linkTextKeystrokeQueue = [];
+ this.labelMap = {};
+ }
+
+};
+
+var hintUtils = {
+ /*
+ * 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("<span class='vimiumReset'>" + hintString[i] + "</span>");
+ return innerHTML.join("");
+ },
+
+ /*
+ * Creates a link marker for the given link.
+ */
+ createMarkerFor: function(link) {
+ var marker = document.createElement("div");
+ marker.className = "vimiumReset internalVimiumHintMarker vimiumHintMarker";
+ marker.clickableItem = link.element;
+
+ var clientRect = link.rect;
+ marker.style.left = clientRect.left + window.scrollX + "px";
+ marker.style.top = clientRect.top + window.scrollY + "px";
+
+ marker.rect = link.rect;
+
+ return marker;
+ }
+};