diff options
| -rw-r--r-- | CREDITS | 1 | ||||
| -rw-r--r-- | background_page.html | 1 | ||||
| -rw-r--r-- | commands.js | 14 | ||||
| -rw-r--r-- | linkHints.js | 751 | ||||
| -rw-r--r-- | options.html | 68 | ||||
| -rw-r--r-- | vimiumFrontend.js | 74 | 
6 files changed, 574 insertions, 335 deletions
| @@ -19,5 +19,6 @@ Contributors:    tsigo    Werner Laurensse (github: ab3)    Svein-Erik Larsen <feinom@gmail.com> (github: feinom) +  Bill Casarin <jb@jb55.com> (github: jb55)  Feel free to add real names in addition to GitHub usernames. diff --git a/background_page.html b/background_page.html index ac83a0c3..106db28d 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/commands.js b/commands.js index 3b9203dc..ec35c569 100644 --- a/commands.js +++ b/commands.js @@ -110,9 +110,9 @@ function clearKeyMappingsAndSetDefaults() {    mapKeyToCommand('gi', 'focusInput'); -  mapKeyToCommand('f',     'activateLinkHintsMode'); -  mapKeyToCommand('F',     'activateLinkHintsModeToOpenInNewTab'); -  mapKeyToCommand('<a-f>', 'activateLinkHintsModeWithQueue'); +  mapKeyToCommand('f',     'linkHints.activateMode'); +  mapKeyToCommand('F',     'linkHints.activateModeToOpenInNewTab'); +  mapKeyToCommand('<a-f>', 'linkHints.activateModeWithQueue');    mapKeyToCommand('/', 'enterFindMode');    mapKeyToCommand('n', 'performFind'); @@ -157,9 +157,9 @@ addCommand('enterInsertMode',     'Enter insert mode');  addCommand('focusInput',          'Focus the first (or n-th) text box on the page', false, true); -addCommand('activateLinkHintsMode',               'Enter link hints mode to open links in current tab'); -addCommand('activateLinkHintsModeToOpenInNewTab', 'Enter link hints mode to open links in new tab'); -addCommand('activateLinkHintsModeWithQueue',      'Enter link hints mode to open multiple links in a new tab'); +addCommand('linkHints.activateMode',               'Enter link hints mode to open links in current tab'); +addCommand('linkHints.activateModeToOpenInNewTab', 'Enter link hints mode to open links in new tab'); +addCommand('linkHints.activateModeWithQueue',      'Enter link hints mode to open multiple links in a new tab');  addCommand('enterFindMode',        'Enter find mode');  addCommand('performFind',          'Cycle forward to the next find match'); @@ -191,7 +191,7 @@ var commandGroups = {       "scrollPageUp", "scrollFullPageUp", "scrollFullPageDown",       "reload", "toggleViewSource", "zoomIn", "zoomOut", "copyCurrentUrl", "goUp",       "enterInsertMode", "focusInput", -     "activateLinkHintsMode", "activateLinkHintsModeToOpenInNewTab", "activateLinkHintsModeWithQueue", +     "linkHints.activateMode", "linkHints.activateModeToOpenInNewTab", "linkHints.activateModeWithQueue",       "enterFindMode", "performFind", "performBackwardsFind", "nextFrame"],    historyNavigation:      ["goBack", "goForward"], diff --git a/linkHints.js b/linkHints.js index 58d6e979..6825bfd0 100644 --- a/linkHints.js +++ b/linkHints.js @@ -1,337 +1,494 @@  /* - * 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 | ..." - */ -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"); -  } -} - -/* - * Builds and displays link hints for every visible clickable item on the page. + * 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.   */ -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 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 childClientRect = element.children[j].getClientRects()[0]; -          if (isVisible(element.children[j], childClientRect)) { -            visibleElements.push({element: element.children[j], rect: childClientRect}); -            break; +var linkHintsPrototype = { +  hintMarkers: [], +  hintMarkerContainingDiv: null, +  // The characters that were typed in while in "link hints" mode. +  hintKeystrokeQueue: [], +  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, + +  init: function() { +    // bind the event handlers to the appropriate instance of the prototype +    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.initHintStringGenerator(visibleElements); +    for (var i = 0; i < visibleElements.length; i++) { +      this.hintMarkers.push(this.createMarkerFor( +            visibleElements[i], linkHintNumber, this.hintStringGenerator.bind(this))); +      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]); +    document.documentElement.appendChild(this.hintMarkerContainingDiv); +  }, + +  /* +   * Takes a number and returns the string label for the hint. +   */  +  hintStringGenerator: function(linkHintNumber) {}, + +  /* +   * A hook for any necessary initialization for hintStringGenerator.  Takes an +   * array of visible elements. Any return value is ignored. +   */ +  initHintStringGenerator: 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') { +            var childClientRect = element.children[j].getClientRects()[0]; +            if (this.isVisible(element.children[j], childClientRect)) { +              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) { +    console.log("Key Down"); +    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(); +      this.normalKeyDownHandler(event);      } -  } 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)) { +  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) { +    if (this.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(); +      this.deactivateMode();      } 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.resetMode(); +      } else if (this.shouldOpenInNewTab) { +        this.simulateClick(matchedLink);          matchedLink.focus(); -        deactivateLinkHintsMode(); +        this.deactivateMode();        } else { -        setTimeout(function() { simulateClick(matchedLink); }, 400); +        setTimeout(this.simulateClick.bind(this, matchedLink), 400);          matchedLink.focus(); -        deactivateLinkHintsMode(); +        this.deactivateMode();        }      } -  } -} - -/* - * 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) { +  }, + +  /* +   * 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.tagName == "INPUT" && selectableTypes.indexOf(element.type) >= 0) || +        element.tagName == "TEXTAREA"; +  }, + +  /* +   * Hides linkMarker if it does not match testString, and shows linkMarker +   * if it does match but was previously hidden. To be used with Array.filter(). +   */ +  toggleHighlights: function(testString, linkMarker) { +    if (linkMarker.getAttribute("hintString").indexOf(testString) == 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); +        linkMarker.childNodes[j].className = (j >= testString.length) ? "" : "matchingCharacter"; +      return true;      } else {        linkMarker.style.display = "none"; +      return false;      } -  } -  return linksMatched; -} - +  }, + +  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); +  }, + +  deactivateMode: function() { +    if (this.hintMarkerContainingDiv) +      this.hintMarkerContainingDiv.parentNode.removeChild(this.hintMarkerContainingDiv); +    this.hintMarkerContainingDiv = null; +    this.hintMarkers = []; +    this.hintKeystrokeQueue = []; +    document.removeEventListener("keydown", this.onKeyDownInMode, true); +    document.removeEventListener("keyup", this.onKeyUpInMode, true); +    this.modeActivated = false; +    HUD.hide(); +  }, + +  resetMode: function() { +    this.deactivateMode(); +    this.activateModeWithQueue(); +  }, + +  /* +   * Creates a link marker for the given link. +   */ +  createMarkerFor: function(link, linkHintNumber, stringGenerator) { +    var hintString = stringGenerator(linkHintNumber); +    var linkText = link.element.innerHTML.toLowerCase(); +    if (linkText == undefined)  +      linkText = ""; +    var marker = document.createElement("div"); +    marker.className = "internalVimiumHintMarker vimiumHintMarker"; +    marker.innerHTML = this.spanWrap(hintString); +    marker.setAttribute("hintString", hintString); +    marker.setAttribute("linkText", linkText); + +    // 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; +  }, + +  /* +   * 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>" + hintString[i].toUpperCase() + "</span>"); +    return innerHTML.join(""); +  }, + +}; + +var linkHints;  /* - * 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. + * Create the instance of linkHints, specialized based on the user settings.   */ -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(""); -} +function initializeLinkHints() { +  linkHints = Object.create(linkHintsPrototype); +  linkHints.init(); + +  if (settings.get('filterLinkHints') != "true") { // the default hinting system + +    linkHints['digitsNeeded'] = 1; + +    linkHints['logXOfBase'] = function(x, base) { return Math.log(x) / Math.log(base); }; + +    linkHints['initHintStringGenerator'] = function(visibleElements) { +      this.digitsNeeded = Math.ceil(this.logXOfBase( +            visibleElements.length, settings.get('linkHintCharacters').length)); +    }; + +    linkHints['hintStringGenerator'] = function(linkHintNumber) { +      return this.numberToHintString(linkHintNumber, this.digitsNeeded); +    }; + +    /* +     * 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. +     */ +    linkHints['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(""); +    }; + +    linkHints['normalKeyDownHandler'] = function (event) { +      var keyChar = getKeyChar(event); +      if (!keyChar) +        return; + +      if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { +        if (this.hintKeystrokeQueue.length == 0) { +          this.deactivateMode(); +        } else { +          this.hintKeystrokeQueue.pop(); +          var matchString = this.hintKeystrokeQueue.join(""); +          this.hintMarkers.filter(this.toggleHighlights.bind(this, matchString)); +        } +      } else if (settings.get('linkHintCharacters').indexOf(keyChar) >= 0) { +        this.hintKeystrokeQueue.push(keyChar); +        var matchString = this.hintKeystrokeQueue.join(""); +        linksMatched = this.hintMarkers.filter(this.toggleHighlights.bind(this, matchString)); +        if (linksMatched.length == 0) +          this.deactivateMode(); +        else if (linksMatched.length == 1) +          this.activateLink(linksMatched[0].clickableItem); +      } +    }; -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); -} +  } else { -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(); -} +    linkHints['linkTextKeystrokeQueue'] = []; + +    linkHints['hintStringGenerator'] = function(linkHintNumber) { +      return (linkHintNumber + 1).toString(); +    }; + +    linkHints['normalKeyDownHandler'] = function(event) { +      if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { +        if (this.linkTextKeystrokeQueue.length == 0 && this.hintKeystrokeQueue.length == 0) { +          this.deactivateMode(); +        } else { +          // backspace clears hint key queue first, then acts on link text key queue +          if (this.hintKeystrokeQueue.pop()) +            this.filterLinkHints(); +          else { +            this.linkTextKeystrokeQueue.pop(); +            this.filterLinkHints(); +          } +        } +      } else { +        var keyChar = getKeyChar(event); +        if (!keyChar) +          return; + +        var linksMatched, matchString; +        if (/[0-9]/.test(keyChar)) { +          this.hintKeystrokeQueue.push(keyChar); +          matchString = this.hintKeystrokeQueue.join(""); +          linksMatched = this.hintMarkers.filter((function(linkMarker) { +            if (linkMarker.getAttribute('filtered') == 'true') +              return false; +            return this.toggleHighlights(matchString, linkMarker); +          }).bind(this)); +        } 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 resetLinkHintsMode() { -  deactivateLinkHintsMode(); -  activateLinkHintsModeWithQueue(); -} +        if (linksMatched.length == 0) +          this.deactivateMode(); +        else if (linksMatched.length == 1) +          this.activateLink(linksMatched[0].clickableItem); +      } +    }; + +    /* +     * 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. +    */ +    linkHints['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.style.display = "none"; +          linkMarker.setAttribute("filtered", "true"); +        } else { +          if (linkMarker.style.display == "none") +            linkMarker.style.display = ""; +          var newHintText = (linksMatched.length+1).toString(); +          linkMarker.innerHTML = this.spanWrap(newHintText); +          linkMarker.setAttribute("hintString", newHintText); +          linkMarker.setAttribute("filtered", "false"); +          linksMatched.push(linkMarker); +        } +      } +      return linksMatched; +    }; -/* - * Creates a link marker for the given link. - */ -function createMarkerFor(link, linkHintNumber, linkHintDigits) { -  var hintString = numberToHintString(linkHintNumber, linkHintDigits); -  var marker = document.createElement("div"); -  marker.className = "internalVimiumHintMarker vimiumHintMarker"; -  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; +    linkHints['deactivateMode'] = function() { +      this.linkTextKeystrokeQueue = []; +      // call(this) is necessary to make deactivateMode reset +      // the variables in linkHints instead of linkHintsPrototype +      Object.getPrototypeOf(this).deactivateMode.call(this); +    }; + +  }  } diff --git a/options.html b/options.html index 344fc01d..f752abcd 100644 --- a/options.html +++ b/options.html @@ -70,7 +70,10 @@        tr.advancedOption {          display:none;        } - +      input:read-only { +        background-color: #eee; +        color: #666; +      }      </style>    <script type="text/javascript"> @@ -80,7 +83,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 +96,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 +115,10 @@        enableSaveButton();    } +  function onDataLoaded() { +    $("linkHintCharacters").readOnly = $("filterLinkHints").checked; +  } +    function enableSaveButton() { $("saveOptions").removeAttribute("disabled"); }    // Saves options to localStorage. @@ -115,15 +127,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 +155,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 +291,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/vimiumFrontend.js b/vimiumFrontend.js index eb3de996..e5e214f7 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(); @@ -90,14 +109,27 @@ function initializePreDomReady() {      sendResponse({}); // Free up the resources used by this open connection.    }); +  /* +   * Takes a dot-notation object string and call the function +   * that it points to with the correct value for 'this'. +   */ +  function invokeCommandString(str, argArray) { +    var components = str.split('.'); +    var obj = this; +    for (var i = 0; i < components.length - 1; i++) +      obj = obj[components[i]]; +    var func = obj[components.pop()]; +    return func.apply(obj, argArray); +  } +    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); +            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++) { invokeCommandString(args.command); }            }          } @@ -128,7 +160,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); @@ -233,14 +265,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, @@ -317,7 +349,7 @@ function toggleViewSourceCallback(url) {  function onKeypress(event) {    var keyChar = ""; -  if (linkHintsModeActivated) +  if (linkHints.modeActivated)      return;    // Ignore modifier keys by themselves. @@ -352,7 +384,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 ? | 
