diff options
| -rw-r--r-- | .gitmodules | 3 | ||||
| -rw-r--r-- | CREDITS | 1 | ||||
| -rw-r--r-- | background_page.html | 1 | ||||
| -rw-r--r-- | lib/utils.js | 54 | ||||
| -rw-r--r-- | linkHints.js | 840 | ||||
| -rw-r--r-- | manifest.json | 3 | ||||
| -rw-r--r-- | options.html | 69 | ||||
| -rw-r--r-- | test_harnesses/automated.html | 253 | ||||
| m--------- | test_harnesses/shoulda.js | 0 | ||||
| -rw-r--r-- | vimiumFrontend.js | 65 | 
10 files changed, 957 insertions, 332 deletions
| diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..d496d533 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "test_harnesses/shoulda.js"] +	path = test_harnesses/shoulda.js +	url = git://github.com/philc/shoulda.js.git @@ -20,6 +20,7 @@ Contributors:    tsigo    Werner Laurensse (github: ab3)    Svein-Erik Larsen <feinom@gmail.com> (github: feinom) +  Bill Casarin <jb@jb55.com> (github: jb55)    R.T. Lechow <rtlechow@gmail.com> (github: rtlechow)  Feel free to add real names in addition to GitHub usernames. diff --git a/background_page.html b/background_page.html index d98967b4..abb91677 100644 --- a/background_page.html +++ b/background_page.html @@ -26,6 +26,7 @@      scrollStepSize: 60,      defaultZoomLevel: 100,      linkHintCharacters: "sadfjklewcmpgh", +    filterLinkHints: false,      userDefinedLinkHintCss:        ".vimiumHintMarker {\n\n}\n" +        ".vimiumHintMarker > .matchingCharacter {\n\n}", diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 00000000..7fc2a6b9 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,54 @@ +var utils = { +  // probably doesn't handle some cases correctly, but it works fine for what +  // we have now +  deepCopy: function(original) { +    var result; +    if (typeof original == 'object') { +      if (original === null) { +        result = null; +      } else { +        result = original.constructor === Array ? [] : {}; +        for (var i in original) +          if (original.hasOwnProperty(i)) +            result[i] = this.deepCopy(original[i]); +      } +    } else { +      result = original; +    } + +    return result; +  }, + +  /* +   * Extends 'original' with 'ext'. If a function in 'ext' also exists in +   * 'original', let the 'original' function be accessible in the new object +   * via a  ._super(functionName as String) method. _Cannot_ be used on its +   * result to achieve 'two-level' inheritance. +   */ +  extendWithSuper: function(original, ext) { +    var result = this.deepCopy(original); +    var tmpSuper = result._super; +    result._superFunctions = {}; +    result._super = function(fname) { return this._superFunctions[fname].bind(this); } +    for (var i in ext) +      if (ext.hasOwnProperty(i)) { +        if (typeof ext[i] == 'function' && typeof original[i] == 'function') +          result._superFunctions[i] = this.deepCopy(original[i]); +        result[i] = this.deepCopy(ext[i]); +      } +    return result; +  }, + +  /* +   * Takes a dot-notation object string and call the function +   * that it points to with the correct value for 'this'. +   */ +  invokeCommandString: function(str, argArray) { +    var components = str.split('.'); +    var obj = window; +    for (var i = 0; i < components.length - 1; i++) +      obj = obj[components[i]]; +    var func = obj[components.pop()]; +    return func.apply(obj, argArray); +  }, +}; diff --git a/linkHints.js b/linkHints.js index 58d6e979..6bd84826 100644 --- a/linkHints.js +++ b/linkHints.js @@ -1,337 +1,581 @@  /* - * 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. + * 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.   * - * 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. - */ - -var hintMarkers = []; -var hintMarkerContainingDiv = null; -// The characters that were typed in while in "link hints" mode. -var hintKeystrokeQueue = []; -var linkHintsModeActivated = false; -var shouldOpenLinkHintInNewTab = false; -var shouldOpenLinkHintWithQueue = false; -// Whether link hint's "open in current/new tab" setting is currently toggled  -var openLinkModeToggle = false; -// Whether we have added to the page the CSS needed to display link hints. -var linkHintsCssAdded = false; - -/*  - * Generate an XPath describing what a clickable element is. - * The final expression will be something like "//button | //xhtml:button | ..." + * 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 clickableElementsXPath = (function() { -  var clickableElements = ["a", "textarea", "button", "select", "input[not(@type='hidden')]", -                           "*[@onclick or @tabindex or @role='link' or @role='button']"]; -  var xpath = []; -  for (var i in clickableElements) -    xpath.push("//" + clickableElements[i], "//xhtml:" + clickableElements[i]); -  return xpath.join(" | ") -})(); - -// We need this as a top-level function because our command system doesn't yet support arguments. -function activateLinkHintsModeToOpenInNewTab() { activateLinkHintsMode(true, false); } - -function activateLinkHintsModeWithQueue() { activateLinkHintsMode(true, true); } - -function activateLinkHintsMode(openInNewTab, withQueue) { -  if (!linkHintsCssAdded) -    addCssToPage(linkHintCss); // linkHintCss is declared by vimiumFrontend.js -  linkHintCssAdded = true; -  linkHintsModeActivated = true; -  setOpenLinkMode(openInNewTab, withQueue); -  buildLinkHints(); -  document.addEventListener("keydown", onKeyDownInLinkHintsMode, true); -  document.addEventListener("keyup", onKeyUpInLinkHintsMode, true); -} - -function setOpenLinkMode(openInNewTab, withQueue) { -  shouldOpenLinkHintInNewTab = openInNewTab; -  shouldOpenLinkHintWithQueue = withQueue; -  if (shouldOpenLinkHintWithQueue) { -    HUD.show("Open multiple links in a new tab"); -  } else { -    if (shouldOpenLinkHintInNewTab) -      HUD.show("Open link in new tab"); -    else -      HUD.show("Open link in current tab"); -  } -} +var linkHints;  /* - * Builds and displays link hints for every visible clickable item on the page. + * Create the instance of linkHints, specialized based on the user settings.   */ -function buildLinkHints() { -  var visibleElements = getVisibleClickableElements(); - -  // Initialize the number used to generate the character hints to be as many digits as we need to -  // highlight all the links on the page; we don't want some link hints to have more chars than others. -  var digitsNeeded = Math.ceil(logXOfBase(visibleElements.length, settings.linkHintCharacters.length)); -  var linkHintNumber = 0; -  for (var i = 0; i < visibleElements.length; i++) { -    hintMarkers.push(createMarkerFor(visibleElements[i], linkHintNumber, digitsNeeded)); -    linkHintNumber++; -  } -  // 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.className = "internalVimiumHintMarker"; -  for (var i = 0; i < hintMarkers.length; i++) -    hintMarkerContainingDiv.appendChild(hintMarkers[i]); -  document.documentElement.appendChild(hintMarkerContainingDiv); +function initializeLinkHints() { +  if (settings.get('filterLinkHints') != "true") // the default hinting system +    linkHints = utils.extendWithSuper(linkHintsBase, alphabetHints); +  else +    linkHints = utils.extendWithSuper(linkHintsBase, filterHints); +  linkHints.init();  } -function logXOfBase(x, base) { return Math.log(x) / Math.log(base); } -  /* - * 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. + * A set of common operations shared by any link-hinting system. Some methods + * are stubbed.   */ -function getVisibleClickableElements() { -  var resultSet = document.evaluate(clickableElementsXPath, document.body, -    function (namespace) { -      return namespace == "xhtml" ? "http://www.w3.org/1999/xhtml" : null; -    }, -    XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); - - -  var visibleElements = []; - -  // Find all visible clickable elements. -  for (var i = 0; i < resultSet.snapshotLength; i++) { -    var element = resultSet.snapshotItem(i); -    var clientRect = element.getClientRects()[0]; - -    if (isVisible(element, clientRect)) -      visibleElements.push({element: element, rect: clientRect}); - -    // If the link has zero dimensions, it may be wrapping visible -    // but floated elements. Check for this. -    if (clientRect && (clientRect.width == 0 || clientRect.height == 0)) { -      for (var j = 0; j < element.children.length; j++) { -        if (window.getComputedStyle(element.children[j], null).getPropertyValue('float') != 'none') { +var linkHintsBase = { +  hintMarkers: [], +  hintMarkerContainingDiv: null, +  // The characters that were typed in while in "link hints" mode. +  modeActivated: false, +  shouldOpenInNewTab: false, +  shouldOpenWithQueue: false, +  // Whether link hint's "open in current/new tab" setting is currently toggled  +  openLinkModeToggle: false, +  // Whether we have added to the page the CSS needed to display link hints. +  cssAdded: false, +  // While in delayMode, all keypresses have no effect. +  delayMode: false, + +  /* +   * To be called after linkHints has been generated from linkHintsBase. +   */ +  init: function() { +    this.onKeyDownInMode = this.onKeyDownInMode.bind(this); +    this.onKeyUpInMode = this.onKeyUpInMode.bind(this); +  }, + +  /*  +   * Generate an XPath describing what a clickable element is. +   * The final expression will be something like "//button | //xhtml:button | ..." +   */ +  clickableElementsXPath: (function() { +    var clickableElements = ["a", "textarea", "button", "select", "input[not(@type='hidden')]", +                             "*[@onclick or @tabindex or @role='link' or @role='button']"]; +    var xpath = []; +    for (var i in clickableElements) +      xpath.push("//" + clickableElements[i], "//xhtml:" + clickableElements[i]); +    return xpath.join(" | ") +  })(), + +  // We need this as a top-level function because our command system doesn't yet support arguments. +  activateModeToOpenInNewTab: function() { this.activateMode(true, false); }, + +  activateModeWithQueue: function() { this.activateMode(true, true); }, + +  activateMode: function (openInNewTab, withQueue) { +    if (!this.cssAdded) +      addCssToPage(linkHintCss); // linkHintCss is declared by vimiumFrontend.js +    this.linkHintCssAdded = true; +    this.modeActivated = true; +    this.setOpenLinkMode(openInNewTab, withQueue); +    this.buildLinkHints(); +    document.addEventListener("keydown", this.onKeyDownInMode, true); +    document.addEventListener("keyup", this.onKeyUpInMode, true); +  }, + +  setOpenLinkMode: function(openInNewTab, withQueue) { +    this.shouldOpenInNewTab = openInNewTab; +    this.shouldOpenWithQueue = withQueue; +    if (this.shouldOpenWithQueue) { +      HUD.show("Open multiple links in a new tab"); +    } else { +      if (this.shouldOpenInNewTab) +        HUD.show("Open link in new tab"); +      else +        HUD.show("Open link in current tab"); +    } +  }, + +  /* +   * Builds and displays link hints for every visible clickable item on the page. +   */ +  buildLinkHints: function() { +    var visibleElements = this.getVisibleClickableElements(); + +    // Initialize the number used to generate the character hints to be as many digits as we need to +    // highlight all the links on the page; we don't want some link hints to have more chars than others. +    var linkHintNumber = 0; +    this.initSetMarkerAttributes(visibleElements); +    for (var i = 0; i < visibleElements.length; i++) { +      this.hintMarkers.push(this.createMarkerFor(visibleElements[i], linkHintNumber)); +      linkHintNumber++; +    } +    // 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.className = "internalVimiumHintMarker"; +    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(); +  }, + +  /* +   * Sets the data attributes of the marker. Does not need to handle styling +   * and positioning. MUST set the hintString and innerHTML properties. +   */  +  setMarkerAttributes: function(marker, linkHintNumber) {}, + +  /* +   * A hook for any necessary initialization for setMarkerAttributes.  Takes an +   * array of visible elements. Any return value is ignored. +   */ +  initSetMarkerAttributes: function(visibleElements) { +  }, + +  /* +   * 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 = document.evaluate(this.clickableElementsXPath, document.body, +      function (namespace) { +        return namespace == "xhtml" ? "http://www.w3.org/1999/xhtml" : null; +      }, +      XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); + + +    var visibleElements = []; + +    // Find all visible clickable elements. +    for (var i = 0; i < resultSet.snapshotLength; i++) { +      var element = resultSet.snapshotItem(i); +      var clientRect = element.getClientRects()[0]; + +      if (this.isVisible(element, clientRect)) +        visibleElements.push({element: element, rect: clientRect}); + +      // If the link has zero dimensions, it may be wrapping visible +      // but floated elements. Check for this. +      if (clientRect && (clientRect.width == 0 || clientRect.height == 0)) { +        for (var j = 0; j < element.children.length; j++) { +          if (window.getComputedStyle(element.children[j], null).getPropertyValue('float') == 'none') +            continue;            var childClientRect = element.children[j].getClientRects()[0]; -          if (isVisible(element.children[j], childClientRect)) { -            visibleElements.push({element: element.children[j], rect: childClientRect}); -            break; -          } +          if (!this.isVisible(element.children[j], childClientRect)) +            continue; +          visibleElements.push({element: element.children[j], rect: childClientRect}); +          break;          }        }      } -  } -  return visibleElements; -} - -/* - * Returns true if element is visible. - */ -function isVisible(element, clientRect) { -  // Exclude links which have just a few pixels on screen, because the link hints won't show for them anyway. -  var zoomFactor = currentZoomLevel / 100.0; -  if (!clientRect || clientRect.top < 0 || clientRect.top * zoomFactor >= window.innerHeight - 4 || -      clientRect.left < 0 || clientRect.left * zoomFactor >= window.innerWidth - 4) -    return false; - -  if (clientRect.width < 3 || clientRect.height < 3) -    return false; - -  // eliminate invisible elements (see test_harnesses/visibility_test.html) -  var computedStyle = window.getComputedStyle(element, null); -  if (computedStyle.getPropertyValue('visibility') != 'visible' || -      computedStyle.getPropertyValue('display') == 'none') -    return false; - -  return true; -} - -function onKeyDownInLinkHintsMode(event) { -  console.log("Key Down"); -  if (event.keyCode == keyCodes.shiftKey && !openLinkModeToggle) { -    // Toggle whether to open link in a new or current tab. -    setOpenLinkMode(!shouldOpenLinkHintInNewTab, shouldOpenLinkHintWithQueue); -    openLinkModeToggle = true; -  } - -  var keyChar = getKeyChar(event); -  if (!keyChar) -    return; +    return visibleElements; +  }, + +  /* +   * Returns true if element is visible. +   */ +  isVisible: function(element, clientRect) { +    // Exclude links which have just a few pixels on screen, because the link hints won't show for them anyway. +    var zoomFactor = currentZoomLevel / 100.0; +    if (!clientRect || clientRect.top < 0 || clientRect.top * zoomFactor >= window.innerHeight - 4 || +        clientRect.left < 0 || clientRect.left * zoomFactor >= window.innerWidth - 4) +      return false; + +    if (clientRect.width < 3 || clientRect.height < 3) +      return false; + +    // eliminate invisible elements (see test_harnesses/visibility_test.html) +    var computedStyle = window.getComputedStyle(element, null); +    if (computedStyle.getPropertyValue('visibility') != 'visible' || +        computedStyle.getPropertyValue('display') == 'none') +      return false; + +    return true; +  }, + +  /* +   * Handles shift and esc keys. The other keys are passed to normalKeyDownHandler. +   */ +  onKeyDownInMode: function(event) { +    if (this.delayMode) +      return; + +    if (event.keyCode == keyCodes.shiftKey && !this.openLinkModeToggle) { +      // Toggle whether to open link in a new or current tab. +      this.setOpenLinkMode(!this.shouldOpenInNewTab, this.shouldOpenWithQueue); +      this.openLinkModeToggle = true; +    } -  // TODO(philc): Ignore keys that have modifiers. -  if (isEscape(event)) { -    deactivateLinkHintsMode(); -  } else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { -    if (hintKeystrokeQueue.length == 0) { -      deactivateLinkHintsMode(); +    // TODO(philc): Ignore keys that have modifiers. +    if (isEscape(event)) { +      this.deactivateMode();      } else { -      hintKeystrokeQueue.pop(); -      updateLinkHints(); +      var keyResult = this.normalKeyDownHandler(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].clickableItem, delay); +      } else { +        for (var i in this.hintMarkers) +          this.hideMarker(this.hintMarkers[i]); +        for (var i in linksMatched) +          this.showMarker(linksMatched[i], this.hintKeystrokeQueue.length); +      }      } -  } else if (settings.linkHintCharacters.indexOf(keyChar) >= 0) { -    hintKeystrokeQueue.push(keyChar); -    updateLinkHints(); -  } else { -    return; -  } -  event.stopPropagation(); -  event.preventDefault(); -} +    event.stopPropagation(); +    event.preventDefault(); +  }, -function onKeyUpInLinkHintsMode(event) { -  if (event.keyCode == keyCodes.shiftKey && openLinkModeToggle) { -    // Revert toggle on whether to open link in new or current tab.  -    setOpenLinkMode(!shouldOpenLinkHintInNewTab, shouldOpenLinkHintWithQueue); -    openLinkModeToggle = false; -  } -  event.stopPropagation(); -  event.preventDefault(); -} +  /* +   * Handle all keys other than shift and esc. Return value is ignored. +   */ +  normalKeyDownHandler: function(event) {}, -/* - * Updates the visibility of link hints on screen based on the keystrokes typed thus far. If only one - * link hint remains, click on that link and exit link hints mode. - */ -function updateLinkHints() { -  var matchString = hintKeystrokeQueue.join(""); -  var linksMatched = highlightLinkMatches(matchString); -  if (linksMatched.length == 0) -    deactivateLinkHintsMode(); -  else if (linksMatched.length == 1) { -    var matchedLink = linksMatched[0]; -    if (isSelectable(matchedLink)) { -      matchedLink.focus(); -      // When focusing a textbox, put the selection caret at the end of the textbox's contents. -      matchedLink.setSelectionRange(matchedLink.value.length, matchedLink.value.length); -      deactivateLinkHintsMode(); +  onKeyUpInMode: function(event) { +    if (event.keyCode == keyCodes.shiftKey && this.openLinkModeToggle) { +      // Revert toggle on whether to open link in new or current tab.  +      this.setOpenLinkMode(!this.shouldOpenInNewTab, this.shouldOpenWithQueue); +      this.openLinkModeToggle = false; +    } +    event.stopPropagation(); +    event.preventDefault(); +  }, + +  /* +   * When only one link hint remains, this function activates it in the appropriate way. +   */ +  activateLink: function(matchedLink, delay) { +    var that = this; +    this.delayMode = true; +    if (this.isSelectable(matchedLink)) { +      this.simulateSelect(matchedLink);      } else { -      // 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 feedback depicting which link they've selected by focusing it. -      if (shouldOpenLinkHintWithQueue) { -        simulateClick(matchedLink); -        resetLinkHintsMode(); -      } else if (shouldOpenLinkHintInNewTab) { -        simulateClick(matchedLink); +      if (this.shouldOpenWithQueue) { +        this.simulateClick(matchedLink); +        this.deactivateMode(delay, function() { +          that.delayMode = false; +          that.activateModeWithQueue(); +        }); +      } else if (this.shouldOpenInNewTab) { +        this.simulateClick(matchedLink);          matchedLink.focus(); -        deactivateLinkHintsMode(); +        this.deactivateMode(delay, function() { that.delayMode = false; });        } else { -        setTimeout(function() { simulateClick(matchedLink); }, 400); +        // 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 feedback depicting which link they've selected by focusing it. +        setTimeout(this.simulateClick.bind(this, matchedLink), 400);          matchedLink.focus(); -        deactivateLinkHintsMode(); +        this.deactivateMode(delay, function() { that.delayMode = false; });        }      } +  }, + +  /* +   * Selectable means the element has a text caret; this is not the same as "focusable". +   */ +  isSelectable: function(element) { +    var selectableTypes = ["search", "text", "password"]; +    return (element.nodeName.toLowerCase() == "input" && selectableTypes.indexOf(element.type) >= 0) || +        element.nodeName.toLowerCase() == "textarea"; +  }, +   +  simulateSelect: function(element) { +    element.focus(); +    // When focusing a textbox, put the selection caret at the end of the textbox's contents. +    element.setSelectionRange(element.value.length, element.value.length); +  }, + +  /* +   * Shows the marker, highlighting matchingCharCount characters. +   */ +  showMarker: function(linkMarker, matchingCharCount) { +    linkMarker.style.display = ""; +    for (var j = 0; j < linkMarker.childNodes.length; j++) +      linkMarker.childNodes[j].className = (j >= matchingCharCount) ? "" : "matchingCharacter"; +  }, + +  hideMarker: function(linkMarker) { +    linkMarker.style.display = "none"; +  }, + +  simulateClick: function(link) { +    var event = document.createEvent("MouseEvents"); +    // 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. +    var metaKey = (platform == "Mac" && linkHints.shouldOpenInNewTab); +    var ctrlKey = (platform != "Mac" && linkHints.shouldOpenInNewTab); +    event.initMouseEvent("click", true, true, window, 1, 0, 0, 0, 0, ctrlKey, false, false, metaKey, 0, null); + +    // Debugging note: Firefox will not execute the link's default action if we dispatch this click event, +    // but Webkit will. Dispatching a click on an input box does not seem to focus it; we do that separately +    link.dispatchEvent(event); +  }, + +  /* +   * If called without arguments, it executes immediately.  Othewise, it +   * executes after 'delay' and invokes 'callback' when it is finished. +   */ +  deactivateMode: function(delay, callback) { +    var that = this; +    function deactivate() { +      if (that.hintMarkerContainingDiv) +        that.hintMarkerContainingDiv.parentNode.removeChild(that.hintMarkerContainingDiv); +      that.hintMarkerContainingDiv = null; +      that.hintMarkers = []; +      that.hintKeystrokeQueue = []; +      document.removeEventListener("keydown", that.onKeyDownInMode, true); +      document.removeEventListener("keyup", that.onKeyUpInMode, true); +      that.modeActivated = false; +      HUD.hide(); +    } +    // we invoke the deactivate() function directly instead of using setTimeout(callback, 0) so that +    // deactivateMode can be tested synchronously +    if (!delay) +      deactivate(); +    else +      setTimeout(function() { deactivate(); if (callback) callback(); }, delay); +  }, + +  /* +   * Creates a link marker for the given link. +   */ +  createMarkerFor: function(link, linkHintNumber) { +    var marker = document.createElement("div"); +    marker.className = "internalVimiumHintMarker vimiumHintMarker"; +    marker.clickableItem = link.element; +    this.setMarkerAttributes(marker, linkHintNumber); + +    // Note: this call will be expensive if we modify the DOM in between calls. +    var clientRect = link.rect; +    // The coordinates given by the window do not have the zoom factor included since the zoom is set only on +    // the document node. +    var zoomFactor = currentZoomLevel / 100.0; +    marker.style.left = clientRect.left + window.scrollX / zoomFactor + "px"; +    marker.style.top = clientRect.top  + window.scrollY / zoomFactor + "px"; + +    return marker; +  }, + +}; + +var alphabetHints = { +  hintKeystrokeQueue: [], +  digitsNeeded: 1, +  logXOfBase: function(x, base) { return Math.log(x) / Math.log(base); }, + +  initSetMarkerAttributes: function(visibleElements) { +    this.digitsNeeded = Math.ceil(this.logXOfBase( +          visibleElements.length, settings.get('linkHintCharacters').length)); +  }, + +  setMarkerAttributes: function(marker, linkHintNumber) { +    var hintString = this.numberToHintString(linkHintNumber, this.digitsNeeded); +    marker.innerHTML = spanWrap(hintString); +    marker.setAttribute("hintString", hintString); +    return marker; +  }, + +  /* +   * 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) { +    var base = settings.get('linkHintCharacters').length; +    var hintString = []; +    var remainder = 0; +    do { +      remainder = number % base; +      hintString.unshift(settings.get('linkHintCharacters')[remainder]); +      number -= remainder; +      number /= Math.floor(base); +    } while (number > 0); + +    // Pad the hint string we're returning so that it matches numHintDigits. +    var hintStringLength = hintString.length; +    for (var i = 0; i < numHintDigits - hintStringLength; i++) +      hintString.unshift(settings.get('linkHintCharacters')[0]); +    return hintString.join(""); +  }, + +  normalKeyDownHandler: function (event, hintMarkers) { +    var keyChar = getKeyChar(event); +    if (!keyChar) +      return hintMarkers; + +    if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { +      if (this.hintKeystrokeQueue.length == 0) { +        return []; +      } else { +        this.hintKeystrokeQueue.pop(); +        var matchString = this.hintKeystrokeQueue.join(""); +        var linksMatched = hintMarkers.filter(function(linkMarker) { +          return linkMarker.getAttribute("hintString").indexOf(matchString) == 0; +        }); +      } +    } else if (settings.get('linkHintCharacters').indexOf(keyChar) >= 0) { +      this.hintKeystrokeQueue.push(keyChar); +      var matchString = this.hintKeystrokeQueue.join(""); +      var linksMatched = hintMarkers.filter(function(linkMarker) { +        return linkMarker.getAttribute("hintString").indexOf(matchString) == 0; +      }); +    } +    return { 'linksMatched': linksMatched };    } -} - -/* - * Selectable means the element has a text caret; this is not the same as "focusable". - */ -function isSelectable(element) { -  var selectableTypes = ["search", "text", "password"]; -  return (element.tagName == "INPUT" && selectableTypes.indexOf(element.type) >= 0) || -      element.tagName == "TEXTAREA"; -} - -/* - * Hides link hints which do not match the given search string. To allow the backspace key to work, this - * will also show link hints which do match but were previously hidden. - */ -function highlightLinkMatches(searchString) { -  var linksMatched = []; -  for (var i = 0; i < hintMarkers.length; i++) { -    var linkMarker = hintMarkers[i]; -    if (linkMarker.getAttribute("hintString").indexOf(searchString) == 0) { -      if (linkMarker.style.display == "none") -        linkMarker.style.display = ""; -      for (var j = 0; j < linkMarker.childNodes.length; j++) -        linkMarker.childNodes[j].className = (j >= searchString.length) ? "" : "matchingCharacter"; -      linksMatched.push(linkMarker.clickableItem); +}; + +var filterHints = { +  hintKeystrokeQueue: [], +  linkTextKeystrokeQueue: [], +  labelMap: {}, + +  /* +   * Generate a map of input element => label +   */ +  initSetMarkerAttributes: function() { +    var labels = document.querySelectorAll("label"); +    for (var i = 0; i < labels.length; 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; +      } +    } +  }, + +  setMarkerAttributes: function(marker, linkHintNumber) { +    var hintString = (linkHintNumber + 1).toString(); +    var linkText = ""; +    var showLinkText = false; +    var element = marker.clickableItem; +    // 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 { -      linkMarker.style.display = "none"; +      linkText = element.textContent || element.innerHTML;      } -  } -  return linksMatched; -} - -/* - * 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. - */ -function numberToHintString(number, numHintDigits) { -  var base = settings.linkHintCharacters.length; -  var hintString = []; -  var remainder = 0; -  do { -    remainder = number % base; -    hintString.unshift(settings.linkHintCharacters[remainder]); -    number -= remainder; -    number /= Math.floor(base); -  } while (number > 0); - -  // Pad the hint string we're returning so that it matches numHintDigits. -  var hintStringLength = hintString.length; -  for (var i = 0; i < numHintDigits - hintStringLength; i++) -    hintString.unshift(settings.linkHintCharacters[0]); -  return hintString.join(""); -} +    linkText = linkText.trim().toLowerCase(); +    marker.setAttribute("hintString", hintString); +    marker.innerHTML = spanWrap(hintString + (showLinkText ? ": " + linkText : "")); +    marker.setAttribute("linkText", linkText); +  }, + +  normalKeyDownHandler: function(event, hintMarkers) { +    var linksMatched; +    if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { +      if (this.linkTextKeystrokeQueue.length == 0 && this.hintKeystrokeQueue.length == 0) { +        return []; +      } else { +        // backspace clears hint key queue first, then acts on link text key queue +        if (this.hintKeystrokeQueue.pop()) +          linksMatched = this.filterLinkHints(); +        else { +          this.linkTextKeystrokeQueue.pop(); +          linksMatched = this.filterLinkHints(); +        } +      } +      return linksMatched; +    } else if (event.keyCode == keyCodes.enter) { +        // activate the lowest-numbered link hint that is visible +        for (var i = 0; i < hintMarkers.length; i++) +          if (hintMarkers[i].style.display  != 'none') +            return [ hintMarkers[i] ]; +    } else { +      var keyChar = getKeyChar(event); +      if (!keyChar) +        return hintMarkers; + +      var matchString; +      if (/[0-9]/.test(keyChar)) { +        this.hintKeystrokeQueue.push(keyChar); +        matchString = this.hintKeystrokeQueue.join(""); +        linksMatched = hintMarkers.filter(function(linkMarker) { +          return linkMarker.getAttribute('filtered') != 'true' +            && linkMarker.getAttribute("hintString").indexOf(matchString) == 0; +        }); +      } else { +        // since we might renumber the hints, the current hintKeyStrokeQueue +        // should be rendered invalid (i.e. reset). +        this.hintKeystrokeQueue = []; +        this.linkTextKeystrokeQueue.push(keyChar); +        matchString = this.linkTextKeystrokeQueue.join(""); +        linksMatched = this.filterLinkHints(matchString); +      } -function simulateClick(link) { -  var event = document.createEvent("MouseEvents"); -  // 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. -  var metaKey = (platform == "Mac" && shouldOpenLinkHintInNewTab); -  var ctrlKey = (platform != "Mac" && shouldOpenLinkHintInNewTab); -  event.initMouseEvent("click", true, true, window, 1, 0, 0, 0, 0, ctrlKey, false, false, metaKey, 0, null); - -  // Debugging note: Firefox will not execute the link's default action if we dispatch this click event, -  // but Webkit will. Dispatching a click on an input box does not seem to focus it; we do that separately -  link.dispatchEvent(event); -} +      if (linksMatched.length == 1 && !/[0-9]/.test(keyChar)) { +        // 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 }; +    } +  }, + +  /* +   * Hides the links that do not match the linkText search string and marks +   * them with the 'filtered' DOM property. Renumbers the remainder.  Should +   * only be called when there is a change in linkTextKeystrokeQueue, to +   * avoid undesired renumbering. +  */ +  filterLinkHints: function(searchString) { +    var linksMatched = []; +    var linkSearchString = this.linkTextKeystrokeQueue.join(""); + +    for (var i = 0; i < this.hintMarkers.length; i++) { +      var linkMarker = this.hintMarkers[i]; +      var matchedLink = linkMarker.getAttribute("linkText").toLowerCase() +                                  .indexOf(linkSearchString.toLowerCase()) >= 0; + +      if (!matchedLink) { +        linkMarker.setAttribute("filtered", "true"); +      } else { +        this.setMarkerAttributes(linkMarker, linksMatched.length); +        linkMarker.setAttribute("filtered", "false"); +        linksMatched.push(linkMarker); +      } +    } +    return linksMatched; +  }, -function deactivateLinkHintsMode() { -  if (hintMarkerContainingDiv) -    hintMarkerContainingDiv.parentNode.removeChild(hintMarkerContainingDiv); -  hintMarkerContainingDiv = null; -  hintMarkers = []; -  hintKeystrokeQueue = []; -  document.removeEventListener("keydown", onKeyDownInLinkHintsMode, true); -  document.removeEventListener("keyup", onKeyUpInLinkHintsMode, true); -  linkHintsModeActivated = false; -  HUD.hide(); -} +  deactivateMode: function(delay, callback) { +    this.linkTextKeystrokeQueue = []; +    this.labelMap = {}; +    this._super('deactivateMode')(delay, callback); +  } -function resetLinkHintsMode() { -  deactivateLinkHintsMode(); -  activateLinkHintsModeWithQueue(); -} +};  /* - * Creates a link marker for the given link. + * Make each hint character a span, so that we can highlight the typed characters as you type them.   */ -function createMarkerFor(link, linkHintNumber, linkHintDigits) { -  var hintString = numberToHintString(linkHintNumber, linkHintDigits); -  var marker = document.createElement("div"); -  marker.className = "internalVimiumHintMarker vimiumHintMarker"; +function spanWrap(hintString) {    var innerHTML = []; -  // Make each hint character a span, so that we can highlight the typed characters as you type them.    for (var i = 0; i < hintString.length; i++)      innerHTML.push("<span>" + hintString[i].toUpperCase() + "</span>"); -  marker.innerHTML = innerHTML.join(""); -  marker.setAttribute("hintString", hintString); - -  // Note: this call will be expensive if we modify the DOM in between calls. -  var clientRect = link.rect; -  // The coordinates given by the window do not have the zoom factor included since the zoom is set only on -  // the document node. -  var zoomFactor = currentZoomLevel / 100.0; -  marker.style.left = clientRect.left + window.scrollX / zoomFactor + "px"; -  marker.style.top = clientRect.top  + window.scrollY / zoomFactor + "px"; - -  marker.clickableItem = link.element; -  return marker; +  return innerHTML.join("");  } diff --git a/manifest.json b/manifest.json index 161bdb30..181c8739 100644 --- a/manifest.json +++ b/manifest.json @@ -15,7 +15,8 @@    "content_scripts": [      {        "matches": ["<all_urls>"], -      "js": ["lib/keyboardUtils.js", +      "js": ["lib/utils.js", +             "lib/keyboardUtils.js",               "lib/clipboard.js",               "linkHints.js",               "vimiumFrontend.js" diff --git a/options.html b/options.html index bfa01450..1724f51b 100644 --- a/options.html +++ b/options.html @@ -1,6 +1,7 @@  <html>    <head>      <title>Vimium Options</title> +    <script src="lib/utils.js"></script>      <script src="lib/keyboardUtils.js"></script>      <script src="linkHints.js"></script>      <script src="lib/clipboard.js"></script> @@ -70,7 +71,10 @@        tr.advancedOption {          display:none;        } - +      input:read-only { +        background-color: #eee; +        color: #666; +      }      </style>    <script type="text/javascript"> @@ -80,7 +84,7 @@    var defaultSettings = chrome.extension.getBackgroundPage().defaultSettings;    var editableFields = ["scrollStepSize", "defaultZoomLevel", "excludedUrls", "linkHintCharacters", -    "userDefinedLinkHintCss", "keyMappings"]; +    "userDefinedLinkHintCss", "keyMappings", "filterLinkHints"];    var postSaveHooks = {      keyMappings: function (value) { @@ -93,8 +97,13 @@    function initializeOptions() {      populateOptions(); -    for (var i = 0; i < editableFields.length; i++) + +    for (var i = 0; i < editableFields.length; i++) {        $(editableFields[i]).addEventListener("keyup", onOptionKeyup, false); +      $(editableFields[i]).addEventListener("change", enableSaveButton, false); +      $(editableFields[i]).addEventListener("change", onDataLoaded, false); +    } +      $("advancedOptions").addEventListener("click", openAdvancedOptions, false);      $("showCommands").addEventListener("click", function () {        showDialog("vimiumCommandListingContainer", @@ -107,6 +116,10 @@        enableSaveButton();    } +  function onDataLoaded() { +    $("linkHintCharacters").readOnly = $("filterLinkHints").checked; +  } +    function enableSaveButton() { $("saveOptions").removeAttribute("disabled"); }    // Saves options to localStorage. @@ -115,15 +128,25 @@      // the freedom to change the defaults in the future.      for (var i = 0; i < editableFields.length; i++) {        var fieldName = editableFields[i]; -      var fieldValue = $(fieldName).value.trim(); +      var field = $(fieldName); + +      var fieldValue; +      if (field.getAttribute("type") == "checkbox") { +        fieldValue = field.checked ? "true" : "false"; +      } else { +        fieldValue = field.value.trim(); +        field.value = fieldValue; +      } +        var defaultFieldValue = (defaultSettings[fieldName] != null) ?          defaultSettings[fieldName].toString() : ""; +        if (fieldValue == defaultFieldValue)          delete localStorage[fieldName];        else          localStorage[fieldName] = fieldValue; -      $(fieldName).value = fieldValue; -      $(fieldName).setAttribute("savedValue", fieldValue); + +      field.setAttribute("savedValue", fieldValue);        if (postSaveHooks[fieldName]) { postSaveHooks[fieldName](fieldValue); }      } @@ -133,17 +156,31 @@    // Restores select box state to saved value from localStorage.    function populateOptions() {      for (var i = 0; i < editableFields.length; i++) { -      $(editableFields[i]).value = localStorage[editableFields[i]] || defaultSettings[editableFields[i]] || ""; -      $(editableFields[i]).setAttribute("savedValue", $(editableFields[i]).value); +      var val = localStorage[editableFields[i]] || defaultSettings[editableFields[i]] || ""; +      var field = $(editableFields[i]); +      setFieldValue($(editableFields[i]), val);      } +    onDataLoaded();    }    function restoreToDefaults() { -    for (var i = 0; i < editableFields.length; i++) -      $(editableFields[i]).value = defaultSettings[editableFields[i]] || ""; +    for (var i = 0; i < editableFields.length; i++) { +      var val = defaultSettings[editableFields[i]] || ""; +      setFieldValue($(editableFields[i]), val); +    } +    onDataLoaded();      enableSaveButton();    } +  function setFieldValue(field, value) { +    if (field.getAttribute('type') == 'checkbox') +      field.checked = value == "true"; +    else +      field.value = value; + +    field.setAttribute("savedValue", value); +  } +    function openAdvancedOptions(event) {      var elements = document.getElementsByClassName("advancedOption");      for (var i = 0; i < elements.length; i++) @@ -255,6 +292,18 @@            <textarea id="userDefinedLinkHintCss" type="text"></textarea>          </td>        </tr> +      <tr class="advancedOption"> +        <td class="caption">Filter link hints</td> +        <td verticalAlign="top"> +          <div class="help"> +            <div class="example"> +              Typing in link hints mode will filter link hints by the link text.<br/><br/> +              Note: You <em>must</em> use numeric link hint characters in this mode +            </div> +          </div> +          <input id="filterLinkHints" type="checkbox"/> +        </td> +      </tr>      </table>      <div id="buttonsPanel"> diff --git a/test_harnesses/automated.html b/test_harnesses/automated.html new file mode 100644 index 00000000..bbfa220f --- /dev/null +++ b/test_harnesses/automated.html @@ -0,0 +1,253 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" +  "http://www.w3.org/TR/html4/strict.dtd"> +<html> +  <head> +    <style type="text/css"> +      body { +        font-family:"helvetica neue", "helvetica", "arial", "sans"; +        width: 800px; +        margin: 0px auto; +      } +      #output-div { +        white-space: pre-wrap; +        background-color: #eee; +        font-family: monospace; +        margin: 0 0 50px 0; +        border-style: dashed; +        border-width: 1px 1px 0 1px; +        border-color: #999; +      } +      .errorPosition { +        color: #f33; +        font-weight: bold; +      } +      .output-section { +        padding: 10px 15px 10px 15px; +        border-bottom: dashed 1px #999; +      } +    </style> +    <script type="text/javascript" src="../lib/utils.js"></script> +    <script type="text/javascript" src="../lib/keyboardUtils.js"></script> +    <script type="text/javascript" src="../linkHints.js"></script> +    <script type="text/javascript" src="../lib/clipboard.js"></script> +    <script type="text/javascript" src="../vimiumFrontend.js"></script> +    <script type="text/javascript" src="shoulda.js/shoulda.js"></script> +    <script type="text/javascript"> +      /* +       * Dispatching keyboard events via the DOM would require async tests, +       * which tend to be more complicated. Here we create mock events and +       * invoke the handlers directly. +       */ +      function mockKeyboardEvent(keyChar) { +        var event = {}; +        event.charCode = keyCodes[keyChar] !== undefined ? keyCodes[keyChar] : keyChar.charCodeAt(0); +        event.keyIdentifier = "U+00" + event.charCode.toString(16); +        event.keyCode = event.charCode; +        event.stopPropagation = function(){}; +        event.preventDefault = function(){}; +        return event; +      } + +      /* +       * Generate tests that are common to both default and filtered +       * link hinting modes. +       */ +      function createGeneralHintTests(isFilteredMode) { +        context("Link hints", + +          setup(function() { +            var testContent = +              "<a>test</a>" + +              "<a>tress</a>"; +            document.getElementById("test-div").innerHTML = testContent; +            settings.values.filterLinkHints = isFilteredMode ? "true" : "false"; +            initializeLinkHints(); +          }), + +          tearDown(function() { +            document.getElementById("test-div").innerHTML = ""; +          }), + +          should("create hints when activated, discard them when deactivated", function() { +            linkHints.activateMode(); +            assert.isFalse(linkHints.hintMarkerContainingDiv == null); +            linkHints.deactivateMode(); +            assert.isTrue(linkHints.hintMarkerContainingDiv == null); +          }), + +          should("position items correctly", function() { +            function assertStartPosition(element1, element2) { +              assert.equal(element1.getClientRects()[0].left, element2.getClientRects()[0].left); +              assert.equal(element1.getClientRects()[0].top, element2.getClientRects()[0].top); +            } +            stub(document.body, "style", "static"); +            linkHints.activateMode(); +            assertStartPosition(document.getElementsByTagName("a")[0], linkHints.hintMarkers[0]); +            assertStartPosition(document.getElementsByTagName("a")[1], linkHints.hintMarkers[1]); +            linkHints.deactivateMode(); + +            stub(document.body.style, "position", "relative"); +            linkHints.activateMode(); +            assertStartPosition(document.getElementsByTagName("a")[0], linkHints.hintMarkers[0]); +            assertStartPosition(document.getElementsByTagName("a")[1], linkHints.hintMarkers[1]); +            linkHints.deactivateMode(); +          }) + +        ); +      } +      createGeneralHintTests(false); +      createGeneralHintTests(true); + +      context("Alphabetical link hints", + +        setup(function() { +          stub(settings.values, "filterLinkHints", "false"); +          initializeLinkHints(); +          // we need at least 16 elements to have double-character link hints +          for (var i = 0; i < 16; i++) { +            var link = document.createElement("a"); +            link.textContent = "test"; +            document.getElementById("test-div").appendChild(link); +          } +          linkHints.activateMode(); +        }), + +        tearDown(function() { +          linkHints.deactivateMode(); +          document.getElementById("test-div").innerHTML = ""; +        }), + +        should("label the hints correctly", function() { +          var hintStrings = ["ss", "sa", "sd"]; +          for (var i = 0; i < 3; i++) +          assert.equal(hintStrings[i], linkHints.hintMarkers[i].getAttribute("hintString")); +        }), + +        should("narrow the hints", function() { +          linkHints.onKeyDownInMode(mockKeyboardEvent("A")); +          assert.equal("none", linkHints.hintMarkers[0].style.display); +          assert.equal("", linkHints.hintMarkers[15].style.display); +        }) + +      ); + +      context("Filtered link hints", + +        setup(function() { +          stub(settings.values, "filterLinkHints", "true"); +          initializeLinkHints(); +        }), + +        context("Text hints", + +          setup(function() { +            var testContent = +              "<a>test</a>" + +              "<a>tress</a>" + +              "<a>trait</a>" + +              "<a>track<img alt='alt text'/></a>"; +            document.getElementById("test-div").innerHTML = testContent; +            linkHints.activateMode(); +          }), + +          tearDown(function() { +            document.getElementById("test-div").innerHTML = ""; +            linkHints.deactivateMode(); +          }), + +          should("label the hints", function() { +            for (var i = 0; i < 4; i++) +              assert.equal((i + 1).toString(), linkHints.hintMarkers[i].textContent.toLowerCase()); +          }), + +          should("narrow the hints", function() { +            linkHints.onKeyDownInMode(mockKeyboardEvent("T")); +            linkHints.onKeyDownInMode(mockKeyboardEvent("R")); +            assert.equal("none", linkHints.hintMarkers[0].style.display); +            assert.equal("1", linkHints.hintMarkers[1].getAttribute("hintString")); +            assert.equal("", linkHints.hintMarkers[1].style.display); +            linkHints.onKeyDownInMode(mockKeyboardEvent("A")); +            assert.equal("2", linkHints.hintMarkers[3].getAttribute("hintString")); +          }) + +        ), + +        context("Image hints", + +          setup(function() { +            var testContent = +              "<a><img alt='alt text'/></a>" + +              "<a><img alt='alt text' title='some title'/></a>" + +              "<a><img title='some title'/></a>" + +              "<a><img src='blah' width='320px' height='100px'/></a>"; +            document.getElementById("test-div").innerHTML = testContent; +            linkHints.activateMode(); +          }), +           +          tearDown(function() { +            document.getElementById("test-div").innerHTML = ""; +            linkHints.deactivateMode(); +          }), + +          should("label the images", function() { +            assert.equal("1: alt text", linkHints.hintMarkers[0].textContent.toLowerCase()); +            assert.equal("2: alt text", linkHints.hintMarkers[1].textContent.toLowerCase()); +            assert.equal("3: some title", linkHints.hintMarkers[2].textContent.toLowerCase()); +            assert.equal("4", linkHints.hintMarkers[3].textContent.toLowerCase()); +          }) + +        ), + +        context("Input hints", + +          setup(function() { +            var testContent = +              "<input type='text' value='some value'/>" + +              "<input type='password' value='some value'/>" + +              "<textarea>some text</textarea>" + +              "<label for='test-input'/>a label</label><input type='text' id='test-input' value='some value'/>" + +              "<label for='test-input-2'/>a label: </label><input type='text' id='test-input-2' value='some value'/>"; +            document.getElementById("test-div").innerHTML = testContent; +            linkHints.activateMode(); +          }), + +          tearDown(function() { +            document.getElementById("test-div").innerHTML = ""; +            linkHints.deactivateMode(); +          }), + +          should("label the input elements", function() { +            assert.equal("1", linkHints.hintMarkers[0].textContent.toLowerCase()); +            assert.equal("2", linkHints.hintMarkers[1].textContent.toLowerCase()); +            assert.equal("3", linkHints.hintMarkers[2].textContent.toLowerCase()); +            assert.equal("4: a label", linkHints.hintMarkers[3].textContent.toLowerCase()); +            assert.equal("5: a label", linkHints.hintMarkers[4].textContent.toLowerCase()); +          }) + +        ) +      ); + +      Tests.outputMethod = function(output) { +        var newOutput = Array.prototype.join.call(arguments, "\n"); +        newOutput = newOutput.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); // escape html +        // highlight the source of the error +        newOutput = newOutput.replace(/\/([^:/]+):([0-9]+):([0-9]+)/, "/<span class='errorPosition'>$1:$2</span>:$3"); +        document.getElementById("output-div").innerHTML += "<div class='output-section'>" + newOutput + "</div>"; +        console.log.apply(console, arguments); +      } +      // ensure the extension has time to load before commencing the tests +      document.addEventListener("DOMContentLoaded", function(){ +        setTimeout(Tests.run, 200); +      }); +    </script> +  </head> +  <body> +    <!-- should always be the first element on the page --> +    <div id="test-div"></div> + +    <h1>Vimium Tests</h1> + +    <div id="output-div"></div> + +  </body> +</html> diff --git a/test_harnesses/shoulda.js b/test_harnesses/shoulda.js new file mode 160000 +Subproject 695d0eb2084de5380dccac8c9b188ce91d838dc diff --git a/vimiumFrontend.js b/vimiumFrontend.js index 7c90aec5..c5b7bdf8 100644 --- a/vimiumFrontend.js +++ b/vimiumFrontend.js @@ -4,8 +4,6 @@   * the page's zoom level. We tell the background page that we're in domReady and ready to accept normal   * commands by connectiong to a port named "domReady".   */ -var settings = {}; -var settingsToLoad = ["scrollStepSize", "linkHintCharacters"];  var getCurrentUrlHandlers = []; // function(url) @@ -37,6 +35,35 @@ var textInputXPath = '//input[' +                       textInputTypes.map(function (type) { return '@type="' + type + '"'; }).join(" or ") +                       ' or not(@type)]'; +var settings = { +  values: {}, +  loadedValues: 0, +  valuesToLoad: ["scrollStepSize", "linkHintCharacters", "filterLinkHints"], + +  get: function (key) { return this.values[key]; }, + +  load: function() { +    for (var i in this.valuesToLoad) { this.sendMessage(this.valuesToLoad[i]); } +  }, + +  sendMessage: function (key) { +    if (!settingPort) +      settingPort = chrome.extension.connect({ name: "getSetting" }); +    settingPort.postMessage({ key: key }); +  }, + +  receiveMessage: function (args) { +    // not using 'this' due to issues with binding on callback +    settings.values[args.key] = args.value; +    if (++settings.loadedValues == settings.valuesToLoad.length) +      settings.initializeOnReady(); +  }, + +  initializeOnReady: function () { +    initializeLinkHints(); +  } +}; +  /*   * Give this frame a unique id.   */ @@ -44,19 +71,11 @@ frameId = Math.floor(Math.random()*999999999)  var hasModifiersRegex = /^<([amc]-)+.>/; -function getSetting(key) { -  if (!settingPort) -    settingPort = chrome.extension.connect({ name: "getSetting" }); -  settingPort.postMessage({ key: key }); -} - -function setSetting(args) { settings[args.key] = args.value; } -  /*   * Complete initialization work that sould be done prior to DOMReady, like setting the page's zoom level.   */  function initializePreDomReady() { -  for (var i in settingsToLoad) { getSetting(settingsToLoad[i]); } +  settings.load();    checkIfEnabledForUrl(); @@ -93,11 +112,11 @@ function initializePreDomReady() {    chrome.extension.onConnect.addListener(function(port, name) {      if (port.name == "executePageCommand") {        port.onMessage.addListener(function(args) { -        if (this[args.command] && frameId == args.frameId) { +        if (frameId == args.frameId) {            if (args.passCountToFunction) { -            this[args.command].call(null, args.count); +            utils.invokeCommandString(args.command, [args.count]);            } else { -            for (var i = 0; i < args.count; i++) { this[args.command].call(); } +            for (var i = 0; i < args.count; i++) { utils.invokeCommandString(args.command); }            }          } @@ -128,7 +147,7 @@ function initializePreDomReady() {            setPageZoomLevel(currentZoomLevel);        });      } else if (port.name == "returnSetting") { -      port.onMessage.addListener(setSetting); +      port.onMessage.addListener(settings.receiveMessage);      } else if (port.name == "refreshCompletionKeys") {        port.onMessage.addListener(function (args) {          refreshCompletionKeys(args.completionKeys); @@ -238,14 +257,14 @@ function scrollToBottom() { window.scrollTo(window.pageXOffset, document.body.sc  function scrollToTop() { window.scrollTo(window.pageXOffset, 0); }  function scrollToLeft() { window.scrollTo(0, window.pageYOffset); }  function scrollToRight() { window.scrollTo(document.body.scrollWidth, window.pageYOffset); } -function scrollUp() { window.scrollBy(0, -1 * settings["scrollStepSize"]); } -function scrollDown() { window.scrollBy(0, settings["scrollStepSize"]); } +function scrollUp() { window.scrollBy(0, -1 * settings.get("scrollStepSize")); } +function scrollDown() { window.scrollBy(0, settings.get("scrollStepSize")); }  function scrollPageUp() { window.scrollBy(0, -1 * window.innerHeight / 2); }  function scrollPageDown() { window.scrollBy(0, window.innerHeight / 2); }  function scrollFullPageUp() { window.scrollBy(0, -window.innerHeight); }  function scrollFullPageDown() { window.scrollBy(0, window.innerHeight); } -function scrollLeft() { window.scrollBy(-1 * settings["scrollStepSize"], 0); } -function scrollRight() { window.scrollBy(settings["scrollStepSize"], 0); } +function scrollLeft() { window.scrollBy(-1 * settings.get("scrollStepSize"), 0); } +function scrollRight() { window.scrollBy(settings.get("scrollStepSize"), 0); }  function focusInput(count) {    var results = document.evaluate(textInputXPath, @@ -322,7 +341,7 @@ function toggleViewSourceCallback(url) {  function onKeypress(event) {    var keyChar = ""; -  if (linkHintsModeActivated) +  if (linkHints.modeActivated)      return;    // Ignore modifier keys by themselves. @@ -357,7 +376,7 @@ function onKeypress(event) {  function onKeydown(event) {    var keyChar = ""; -  if (linkHintsModeActivated) +  if (linkHints.modeActivated)      return;    // handle modifiers being pressed.don't handle shiftKey alone (to avoid / being interpreted as ? @@ -481,7 +500,7 @@ function isFocusable(element) { return isEditable(element) || isEmbed(element);   * Embedded elements like Flash and quicktime players can obtain focus but cannot be programmatically   * unfocused.   */ -function isEmbed(element) { return ["EMBED", "OBJECT"].indexOf(element.tagName) > 0; } +function isEmbed(element) { return ["embed", "object"].indexOf(element.nodeName.toLowerCase()) > 0; }  /*   * Input or text elements are considered focusable and able to receieve their own keyboard events, @@ -494,7 +513,7 @@ function isEditable(target) {    if (target.getAttribute("contentEditable") == "true")      return true;    var focusableInputs = ["input", "textarea", "select", "button"]; -  return focusableInputs.indexOf(target.tagName.toLowerCase()) >= 0; +  return focusableInputs.indexOf(target.nodeName.toLowerCase()) >= 0;  }  function enterInsertMode() { | 
