diff options
| author | Phil Crosby | 2012-06-11 19:02:55 -0700 |
|---|---|---|
| committer | Phil Crosby | 2012-06-12 22:00:35 -0700 |
| commit | f33de347f0de1448446eac462892addbe7a3c461 (patch) | |
| tree | a91bb72412d0d74e07a43cea834a3642a6ecdb76 /content_scripts/link_hints.js | |
| parent | a24a6274b35709002700b3e1d8b2c9ac4376e500 (diff) | |
| download | vimium-f33de347f0de1448446eac462892addbe7a3c461.tar.bz2 | |
Port link_hints.js to coffeescript
Diffstat (limited to 'content_scripts/link_hints.js')
| -rw-r--r-- | content_scripts/link_hints.js | 555 |
1 files changed, 0 insertions, 555 deletions
diff --git a/content_scripts/link_hints.js b/content_scripts/link_hints.js deleted file mode 100644 index 5c0cd3bd..00000000 --- a/content_scripts/link_hints.js +++ /dev/null @@ -1,555 +0,0 @@ -/* - * 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: KeyboardUtils.platform == "Mac", - ctrlKey: KeyboardUtils.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 (KeyboardUtils.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) { - // If a shifted-character is typed, treat it as lowerase for the purposes of matching hints. - var keyChar = KeyboardUtils.getKeyChar(event).toLowerCase(); - - 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 = KeyboardUtils.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; - } -}; |
