diff options
Diffstat (limited to 'content_scripts/link_hints.js')
| -rw-r--r-- | content_scripts/link_hints.js | 552 | 
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; +  } +}; | 
