diff options
Diffstat (limited to 'content_scripts/link_hints.coffee')
| -rw-r--r-- | content_scripts/link_hints.coffee | 496 | 
1 files changed, 496 insertions, 0 deletions
| 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 | 
