diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cakefile | 4 | ||||
| -rw-r--r-- | content_scripts/link_hints.coffee | 496 | ||||
| -rw-r--r-- | content_scripts/link_hints.js | 555 | 
4 files changed, 499 insertions, 557 deletions
| @@ -1,6 +1,7 @@  background_scripts/completion.js  background_scripts/commands.js  background_scripts/settings.js +content_scripts/link_hints.js  tests/completion_test.js  tests/test_helper.js  tests/utils_test.js @@ -2,11 +2,11 @@ fs = require "fs"  {spawn, exec} = require "child_process"  task "build", "compile all coffeescript files to javascript", -> -  coffee = spawn "coffee", ["tests", "background_scripts", "lib"] +  coffee = spawn "coffee", ["tests", "background_scripts", "content_scripts", "lib"]    coffee.stdout.on "data", (data) -> console.log data.toString().trim()  task "autobuild", "continually rebuild coffeescript files using coffee --watch", -> -  coffee = spawn "coffee", ["-cw", "tests", "background_scripts", "lib"] +  coffee = spawn "coffee", ["-cw", "tests", "background_scripts", "content_scripts", "lib"]    coffee.stdout.on "data", (data) -> console.log data.toString().trim()  task "test", "run all unit tests", -> diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee new file mode 100644 index 00000000..e454a1b6 --- /dev/null +++ b/content_scripts/link_hints.coffee @@ -0,0 +1,496 @@ +# +# 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. +# +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: -> +    @onKeyDownInMode = @onKeyDownInMode.bind(this) +    @markerMatcher = if settings.get("filterLinkHints") then filterHints else 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: -> @activateMode(true, false, false) +  activateModeToCopyLinkUrl: -> @activateMode(null, false, true) +  activateModeWithQueue: -> @activateMode(true, true, false) + +  activateMode: (openInNewTab, withQueue, copyLinkUrl) -> +    if (!document.getElementById("vimiumLinkHintCss")) +      # linkHintCss is declared by vimiumFrontend.js and contains the user supplied css overrides. +      addCssToPage(linkHintCss, "vimiumLinkHintCss") +    @setOpenLinkMode(openInNewTab, withQueue, copyLinkUrl) +    @buildLinkHints() +    # handlerStack is declared by vimiumFrontend.js +    handlerStack.push({ +      keydown: @onKeyDownInMode, +      # trap all key events +      keypress: -> false +      keyup: -> false +    }) + +  setOpenLinkMode: (openInNewTab, withQueue, copyLinkUrl) -> +    @shouldOpenInNewTab = openInNewTab +    @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") +      @linkActivator = (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") +      @linkActivator = (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. +      @linkActivator = (link) -> setTimeout(DomUtils.simulateClick.bind(DomUtils, link), 400) + +  # +  # Builds and displays link hints for every visible clickable item on the page. +  # +  buildLinkHints: -> +    visibleElements = @getVisibleClickableElements() +    @hintMarkers = @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. +    @hintMarkerContainingDiv = document.createElement("div") +    @hintMarkerContainingDiv.id = "vimiumHintMarkerContainer" +    @hintMarkerContainingDiv.className = "vimiumReset" +    @hintMarkerContainingDiv.appendChild(marker) for marker in @hintMarkers + +    # sometimes this is triggered before documentElement is created +    # TODO(int3): fail more gracefully? +    if (document.documentElement) +      document.documentElement.appendChild(@hintMarkerContainingDiv) +    else +      @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: -> +    resultSet = DomUtils.evaluateXPath(@clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) + +    visibleElements = [] + +    # Find all visible clickable elements. +    for i in [0...resultSet.snapshotLength] +    # for (i = 0, count = resultSet.snapshotLength; i < count; i++) { +      element = resultSet.snapshotItem(i) +      clientRect = DomUtils.getVisibleClientRect(element, clientRect) +      if (clientRect != null) +        visibleElements.push({element: element, rect: clientRect}) + +      if (element.localName == "area") +        map = element.parentElement +        continue unless map +        img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']") +        continue unless img +        imgClientRects = img.getClientRects() +        continue if (imgClientRects.length == 0) +        c = element.coords.split(/,/) +        coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)] +        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}) + +    visibleElements + +  # +  # Handles shift and esc keys. The other keys are passed to markerMatcher.matchHintsByKey. +  # +  onKeyDownInMode: (event) -> +    return if @delayMode + +    if (event.keyCode == keyCodes.shiftKey && @shouldOpenInNewTab != null) +      # Toggle whether to open link in a new or current tab. +      @setOpenLinkMode(!@shouldOpenInNewTab, @shouldOpenWithQueue, false) +      handlerStack.push({ +        keyup: (event) -> +          return if (event.keyCode != keyCodes.shiftKey) +          LinkHints.setOpenLinkMode(!LinkHints.shouldOpenInNewTab, LinkHints.shouldOpenWithQueue, false) +          handlerStack.pop() +      }) + +    # TODO(philc): Ignore keys that have modifiers. +    if (KeyboardUtils.isEscape(event)) +      @deactivateMode() +    else +      keyResult = @markerMatcher.matchHintsByKey(event, @hintMarkers) +      linksMatched = keyResult.linksMatched +      delay = (if keyResult.delay? then keyResult.delay else 0) +      if (linksMatched.length == 0) +        @deactivateMode() +      else if (linksMatched.length == 1) +        @activateLink(linksMatched[0], delay) +      else +        for i of @hintMarkers +          @hideMarker(@hintMarkers[i]) +        for i of linksMatched +          @showMarker(linksMatched[i], @markerMatcher.hintKeystrokeQueue.length) + +  # +  # When only one link hint remains, this function activates it in the appropriate way. +  # +  activateLink: (matchedLink, delay) -> +    @delayMode = true +    clickEl = matchedLink.clickableItem +    if (DomUtils.isSelectable(clickEl)) +      DomUtils.simulateSelect(clickEl) +      @deactivateMode(delay, -> 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) +      @linkActivator(clickEl) +      if (@shouldOpenWithQueue) +        @deactivateMode delay, -> +          LinkHints.delayMode = false +          LinkHints.activateModeWithQueue() +      else +        @deactivateMode(delay, -> LinkHints.delayMode = false) + +  # +  # Shows the marker, highlighting matchingCharCount characters. +  # +  showMarker: (linkMarker, matchingCharCount) -> +    linkMarker.style.display = "" +    # TODO(philc):  +    for j in [0...linkMarker.childNodes.length] +      if (j < matchingCharCount) +        linkMarker.childNodes[j].classList.add("matchingCharacter") +      else +        linkMarker.childNodes[j].classList.remove("matchingCharacter") + +  hideMarker: (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: (delay, callback) -> +    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() +      callback() if (callback) +    else +      setTimeout(-> +          deactivate() +          callback() if callback +        delay) + +alphabetHints = +  hintKeystrokeQueue: [] +  logXOfBase: (x, base) -> Math.log(x) / Math.log(base) + +  getHintMarkers: (visibleElements) -> +    hintStrings = @hintStrings(visibleElements.length) +    hintMarkers = [] +    for i in [0...visibleElements.length] +      marker = hintUtils.createMarkerFor(visibleElements[i]) +      marker.hintString = hintStrings[i] +      marker.innerHTML = hintUtils.spanWrap(marker.hintString.toUpperCase()) +      hintMarkers.push(marker) + +    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: (linkCount) -> +    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. +    digitsNeeded = Math.ceil(@logXOfBase(linkCount, linkHintCharacters.length)) +    # Short hints are the number of hints we can possibly show which are (digitsNeeded - 1) digits in length. +    shortHintCount = Math.floor( +        (Math.pow(linkHintCharacters.length, digitsNeeded) - linkCount) / +        linkHintCharacters.length) +    longHintCount = linkCount - shortHintCount + +    hintStrings = [] + +    if (digitsNeeded > 1) +      for i in [0...shortHintCount] +        hintStrings.push(@numberToHintString(i, digitsNeeded - 1, linkHintCharacters)) + +    start = shortHintCount * linkHintCharacters.length +    for i in [start...(start + longHintCount)] +      hintStrings.push(@numberToHintString(i, digitsNeeded, linkHintCharacters)) + +    @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: (hints, characterSetLength) -> +    buckets = [] +    buckets[i] = [] for i in [0...characterSetLength] +    for i in [0...hints.length] +      buckets[i % buckets.length].push(hints[i]) +    result = [] +    for i in [0...buckets.length] +      result = result.concat(buckets[i]) +    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: (number, numHintDigits, characterSet) -> +    base = characterSet.length +    hintString = [] +    remainder = 0 +    loop +      remainder = number % base +      hintString.unshift(characterSet[remainder]) +      number -= remainder +      number /= Math.floor(base) +      break unless 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! +    hintStringLength = hintString.length +    for i in [0...(numHintDigits - hintStringLength)] +      hintString.unshift(characterSet[0]) + +    hintString.join("") + +  matchHintsByKey: (event, hintMarkers) -> +    # If a shifted-character is typed, treat it as lowerase for the purposes of matching hints. +    keyChar = KeyboardUtils.getKeyChar(event).toLowerCase() + +    if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) +      if (!@hintKeystrokeQueue.pop()) +        return { linksMatched: [] } +    else if (keyChar && settings.get("linkHintCharacters").indexOf(keyChar) >= 0) +      @hintKeystrokeQueue.push(keyChar) + +    matchString = @hintKeystrokeQueue.join("") +    linksMatched = hintMarkers.filter((linkMarker) -> linkMarker.hintString.indexOf(matchString) == 0) +    { linksMatched: linksMatched } + +  deactivate: -> @hintKeystrokeQueue = [] + +filterHints = +  hintKeystrokeQueue: [] +  linkTextKeystrokeQueue: [] +  labelMap: {} + +  # +  # Generate a map of input element => label +  # +  generateLabelMap: -> +    labels = document.querySelectorAll("label") +    for i in [0...labels.length] +      forElement = labels[i].getAttribute("for") +      if (forElement) +        labelText = labels[i].textContent.trim() +        # remove trailing : commonly found in labels +        if (labelText[labelText.length-1] == ":") +          labelText = labelText.substr(0, labelText.length-1) +        @labelMap[forElement] = labelText + +  generateHintString: (linkHintNumber) -> (linkHintNumber + 1).toString() + +  generateLinkText: (element) -> +    linkText = "" +    showLinkText = false +    # toLowerCase is necessary as html documents return "IMG" and xhtml documents return "img" +    nodeName = element.nodeName.toLowerCase() + +    if (nodeName == "input") +      if (@labelMap[element.id]) +        linkText = @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 +      showLinkText = true if (linkText) +    else +      linkText = element.textContent || element.innerHTML + +    { text: linkText, show: showLinkText } + +  renderMarker: (marker) -> +    marker.innerHTML = hintUtils.spanWrap(marker.hintString + +        (if marker.showLinkText then ": " + marker.linkText else "")) + +  getHintMarkers: (visibleElements) -> +    @generateLabelMap() +    hintMarkers = [] +    for i in [0...visibleElements.length] +      marker = hintUtils.createMarkerFor(visibleElements[i]) +      marker.hintString = @generateHintString(i) +      linkTextObject = @generateLinkText(marker.clickableItem) +      marker.linkText = linkTextObject.text +      marker.showLinkText = linkTextObject.show +      @renderMarker(marker) +      hintMarkers.push(marker) + +    hintMarkers + +  matchHintsByKey: (event, hintMarkers) -> +    keyChar = KeyboardUtils.getKeyChar(event) +    delay = 0 +    userIsTypingLinkText = false + +    if (event.keyCode == keyCodes.enter) +      # activate the lowest-numbered link hint that is visible +      for i in [0...hintMarkers.length] +        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 (!@hintKeystrokeQueue.pop() && !@linkTextKeystrokeQueue.pop()) +        return { linksMatched: [] } +    else if (keyChar) +      if (/[0-9]/.test(keyChar)) +        @hintKeystrokeQueue.push(keyChar) +      else +        # since we might renumber the hints, the current hintKeyStrokeQueue +        # should be rendered invalid (i.e. reset). +        @hintKeystrokeQueue = [] +        @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. +    linksMatched = @filterLinkHints(hintMarkers) +    matchString = @hintKeystrokeQueue.join("") +    linksMatched = linksMatched.filter((linkMarker) -> +      !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. +      delay = 200 + +    { 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: (hintMarkers) -> +    linksMatched = [] +    linkSearchString = @linkTextKeystrokeQueue.join("") + +    for i in [0...hintMarkers.length] +      linkMarker = hintMarkers[i] +      matchedLink = linkMarker.linkText.toLowerCase().indexOf(linkSearchString.toLowerCase()) >= 0 + +      if (!matchedLink) +        linkMarker.filtered = true +      else +        linkMarker.filtered = false +        oldHintString = linkMarker.hintString +        linkMarker.hintString = @generateHintString(linksMatched.length) +        @renderMarker(linkMarker) if (linkMarker.hintString != oldHintString) +        linksMatched.push(linkMarker) + +    linksMatched + +  deactivate: (delay, callback) -> +    @hintKeystrokeQueue = [] +    @linkTextKeystrokeQueue = [] +    @labelMap = {} + +hintUtils = +  # +  # Make each hint character a span, so that we can highlight the typed characters as you type them. +  # +  spanWrap: (hintString) -> +    innerHTML = [] +    for i in [0...hintString.length] +      innerHTML.push("<span class='vimiumReset'>" + hintString[i] + "</span>") +    innerHTML.join("") + +  # +  # Creates a link marker for the given link. +  # +  createMarkerFor: (link) -> +    marker = document.createElement("div") +    marker.className = "vimiumReset internalVimiumHintMarker vimiumHintMarker" +    marker.clickableItem = link.element + +    clientRect = link.rect +    marker.style.left = clientRect.left + window.scrollX + "px" +    marker.style.top = clientRect.top  + window.scrollY  + "px" + +    marker.rect = link.rect + +    marker + +root = exports ? window +root.LinkHints = LinkHints 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; -  } -}; | 
