diff options
| -rw-r--r-- | .gitmodules | 3 | ||||
| -rw-r--r-- | CREDITS | 5 | ||||
| -rw-r--r-- | README.markdown | 38 | ||||
| -rw-r--r-- | background_page.html | 137 | ||||
| -rw-r--r-- | bookmarks.js | 131 | ||||
| -rw-r--r-- | commands.js | 47 | ||||
| -rw-r--r-- | completionDialog.js | 180 | ||||
| -rw-r--r-- | helpDialog.html | 27 | ||||
| -rw-r--r-- | lib/keyboardUtils.js | 2 | ||||
| -rw-r--r-- | lib/utils.js | 33 | ||||
| -rw-r--r-- | linkHints.js | 892 | ||||
| -rw-r--r-- | manifest.json | 10 | ||||
| -rw-r--r-- | options.html | 87 | ||||
| -rw-r--r-- | test_harnesses/automated.html | 252 | ||||
| -rw-r--r-- | test_harnesses/iframe.html | 2 | ||||
| m--------- | test_harnesses/shoulda.js | 0 | ||||
| -rw-r--r-- | vimiumFrontend.js | 444 | 
17 files changed, 1653 insertions, 637 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 @@ -10,18 +10,23 @@ Contributors:    Christian Stefanescu (github: stchris)    ConradIrwin    drizzd +  gpurkins    hogelog    int3    Johannes Emerich (github: knuton)    Julian Naydichev <rublind@gmail.com> (github: naydichev)    lack    markstos +  Matthew Cline <matt@nightrealms.com> +  Murph (github: pandeiro)    rodimius    Tim Morgan <tim@timmorgan.org> (github: seven1m)    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) +  Justin Blake <justin@hentzia.com> (github: blaix)    Wang Ning <daning106@gmail.com> (github:daning)  Feel free to add real names in addition to GitHub usernames. diff --git a/README.markdown b/README.markdown index 6adb3714..1a9294f5 100644 --- a/README.markdown +++ b/README.markdown @@ -26,6 +26,7 @@ Modifier keys are specified as <c-x> <m-x>, <a-x> for ctrl+x,  respectively. See the next section for instructions on modifying these bindings.  Navigating the current page: +      ?       show the help dialog for a list of all available keys      h       scroll left      j       scroll down @@ -39,29 +40,34 @@ Navigating the current page:      F       open a link in a new tab      r       reload      gs      view source -    zi      zoom in -    zo      zoom out      i       enter insert mode -- all commands will be ignored until you hit esc to exit      yy      copy the current url to the clipboard +    yf      copy a link url to the clipboard      gf      cycle forward to the next frame  Using find: +      /       enter find mode -- type your search query and hit enter to search or esc to cancel      n       cycle forward to the next find match      N       cycle backward to the previous find match  Navigating your history: +      H       go back in history      L       go forward in history  Manipulating tabs: +      J, gT      go one tab left      K, gt      go one tab right +    g0         go to the first tab +    g$         go to the last tab      t          create tab      x          close current tab      X          restore closed tab (i.e. unwind the 'x' command)  Additional advanced browsing commands: +      ]]      Follow the link labeled 'next' or '>'. Helpful for browsing paginated sites.      [[      Follow the link labeled 'previous' or '<'. Helpful for browsing paginated sites.      <a-f>   open multiple links in a new tab @@ -69,8 +75,6 @@ Additional advanced browsing commands:      gu      go up one level in the URL hierarchy      zH      scroll all the way left      zL      scroll all the way right -    z0      reset zoom to default value -  Vimium supports command repetition so, for example, hitting '5t' will open 5 tabs in rapid succession. ESC (or  <c-[>) will clear any partial commands in the queue and will also exit insert and find modes. @@ -131,9 +135,30 @@ don't exceed 110 characters.  Release Notes  ------------- -1.27 +1.30 (12/04/2011) + +- Support for image maps in link hints. +- Counts now work with forward & backward navigation. +- Tab & shift-tab to navigate bookmarks dialog. +- An alternate link hints mode: type the title of a link to select it. You can enable it in Vimium's Advanced Preferences. +- Bug fixes. + +1.29 (07/30/2011) + +- `yf` to copy a link hint url to the clipboard. +- Scatter link hints to prevent clustering on dense sites. +- Don't show insert mode notification unless you specifically hit `i`. +- Remove zooming functionality now that Chrome does it all natively. + +1.28 (06/29/2011) + +- Support for opening bookmarks (`b` and `B`). +- Support for contenteditable text boxes. +- Speed improvements and bugfixes. + +1.27 (03/24/2011) -- Bugfixes. +- Improvements and bugfixes.  1.26 (02/17/2011) @@ -145,7 +170,6 @@ Release Notes  -  Some sites are now excluded by default.  -  View source (`gs`) now opens in a new tab.  -  Support for browsing paginated sites using `]]` and `[[` to go forward and backward respectively. --  `z0` will reset the zoom level for the current page.  -  Many of the less-used commands are now marked as "advanced" and hidden in the help dialog by default, so     that the core command set is more focused and approachable.  -  Improvements to link hinting. diff --git a/background_page.html b/background_page.html index 2be1e7b2..7d8f749d 100644 --- a/background_page.html +++ b/background_page.html @@ -25,15 +25,23 @@    var defaultSettings = {      scrollStepSize: 60, -    defaultZoomLevel: 100,      linkHintCharacters: "sadfjklewcmpgh", +    filterLinkHints: false,      userDefinedLinkHintCss:        ".vimiumHintMarker {\n\n}\n" +        ".vimiumHintMarker > .matchingCharacter {\n\n}",      excludedUrls: "http*://mail.google.com/*\n" +                    "http*://www.google.com/reader/*\n", -    previousPatterns: "\\bprev\\b,\\bprevious\\b,\\u00AB,<<,<", -    nextPatterns: "\\bnext\\b,\\u00BB,>>,\\bmore\\b,>" + +    // NOTE : If a page contains both a single angle-bracket link and a double angle-bracket link, then in +    // most cases the single bracket link will be "prev/next page" and the double bracket link will be +    // "first/last page", so we put the single bracket first in the pattern string so that it gets searched +    // for first. + +    // "\bprev\b,\bprevious\b,\bback\b,<,←,«,≪,<<" +    previousPatterns: "prev,previous,back,<,\u2190,\xab,\u226a,<<", +    // "\bnext\b,\bmore\b,>,→,»,≫,>>" +    nextPatterns: "next,more,>,\u2192,\xbb,\u226b,>>",    };    // This is the base internal link hints CSS. It's combined with the userDefinedLinkHintCss before @@ -65,13 +73,12 @@      keyDown:              handleKeyDown,      returnScrollPosition: handleReturnScrollPosition,      getCurrentTabUrl:     getCurrentTabUrl, -    getZoomLevel:         getZoomLevel, -    saveZoomLevel:        saveZoomLevel, -    getSetting:           getSetting +    getSetting:           getSetting, +    getBookmarks:         getBookmarks    };    var sendRequestHandlers = { -    getCompletionKeys: getCompletionKeys, +    getCompletionKeys: getCompletionKeysRequest,      getLinkHintCss: getLinkHintCss,      openUrlInNewTab: openUrlInNewTab,      openUrlInCurrentTab: openUrlInCurrentTab, @@ -81,6 +88,7 @@      upgradeNotificationClosed: upgradeNotificationClosed,      updateScrollPosition: handleUpdateScrollPosition,      copyToClipboard: copyToClipboard, +    copyLinkUrl: copyLinkUrl,      isEnabledForUrl: isEnabledForUrl,      saveHelpDialogSettings: saveHelpDialogSettings    }; @@ -158,17 +166,6 @@      localStorage["helpDialog_showAdvancedCommands"] = request.showAdvancedCommands;    } -  /* -   * Returns the previously saved zoom level for the current tab, or the default zoom level -   */ -  function getZoomLevel(args, port) { -    var returnPort = chrome.tabs.connect(port.tab.id, { name: "returnZoomLevel" }); -    var localStorageKey = "zoom" + args.domain; -    var zoomLevelForDomain = (localStorage[localStorageKey] || "").split(",")[1]; -    var zoomLevel = parseInt(zoomLevelForDomain || getSettingFromLocalStorage("defaultZoomLevel")); -    returnPort.postMessage({ zoomLevel: zoomLevel }); -  } -    function showHelp(callback, frameId) {      chrome.tabs.getSelected(null, function(tab) {        chrome.tabs.sendRequest(tab.id, @@ -236,8 +233,11 @@    /**     * Returns the keys that can complete a valid command given the current key queue.     */ -  function getCompletionKeys(request) { -    return {completionKeys: generateCompletionKeys()}; +  function getCompletionKeysRequest(request) { +    return { name: "refreshCompletionKeys", +             completionKeys: generateCompletionKeys(), +             validFirstKeys: validFirstKeys +           };    }    /** @@ -258,6 +258,14 @@        chrome.tabs.create({ url: request.url, index: tab.index + 1, selected: request.selected });      });    } + +  /** +   * Copies url of selected link to the clipboard (wget ftw) +   */ +  function copyLinkUrl(request) { +    Clipboard.copy(request.data); +  } +    /*     * Returns the core CSS used for link hints, along with any user-provided overrides.     */ @@ -290,6 +298,12 @@      returnPort.postMessage({ key: args.key, value: value });    } +  function getBookmarks(args, port) { +    chrome.bookmarks.search(args.query, function(bookmarks) { +        port.postMessage({bookmarks:bookmarks}) +    }) +  } +    /*     * Used by everyone to get settings from local storage.     */ @@ -301,16 +315,6 @@      }    } -  /* -   * Persists the current zoom level for a given domain -   */ -  function saveZoomLevel(args) { -    var localStorageKey = "zoom" + args.domain; -    // TODO(philc): We might want to consider expiring these entries after X months as NoSquint does. -    // Note(philc): We might also want to jsonify this hash instead of polluting our local storage keyspace. -    localStorage[localStorageKey] = [getCurrentTimeInSeconds(), args.zoomLevel].join(","); -  } -    function getCurrentTimeInSeconds() { Math.floor((new Date()).getTime() / 1000); }    chrome.tabs.onSelectionChanged.addListener(function(tabId, selectionInfo) { @@ -337,23 +341,34 @@    function nextTab(callback) { selectTab(callback, "next"); }    function previousTab(callback) { selectTab(callback, "previous"); } +  function firstTab(callback) { selectTab(callback, "first"); } +  function lastTab(callback) { selectTab(callback, "last"); }    /* -   * Selects a tab before or after the currently selected tab. Direction is either "next" or "previous". +   * Selects a tab before or after the currently selected tab. Direction is either "next", "previous", "first" or "last".     */    function selectTab(callback, direction) {      chrome.tabs.getAllInWindow(null, function(tabs) {        if (tabs.length <= 1)          return; -      for (var i = 0; i < tabs.length; i++) { -        if (tabs[i].selected) { -          var delta = (direction == "next") ? 1 : -1; -          var toSelect = tabs[(i + delta + tabs.length) % tabs.length]; +      chrome.tabs.getSelected(null, function(currentTab) { +          switch (direction) { +            case "next": +              toSelect = tabs[(currentTab.index + 1 + tabs.length) % tabs.length]; +              break; +            case "previous": +              toSelect = tabs[(currentTab.index - 1 + tabs.length) % tabs.length]; +              break; +            case "first": +              toSelect = tabs[0]; +              break; +            case "last": +              toSelect = tabs[tabs.length - 1]; +              break; +          }            selectionChangedHandlers.push(callback);            chrome.tabs.update(toSelect.id, { selected: true }); -          break; -        } -      } +      });      });    } @@ -368,6 +383,8 @@    function updateOpenTabs(tab) {      openTabs[tab.id] = { url: tab.url, positionIndex: tab.index, windowId: tab.windowId }; +    // Frames are recreated on refresh +    delete framesForTab[tab.id];    }    function handleUpdateScrollPosition(request, sender) { @@ -502,7 +519,7 @@      populateValidFirstKeys();      populateSingleKeyCommands(); -    sendRequestToAllTabs({ name: "refreshCompletionKeys", completionKeys: generateCompletionKeys() }); +    sendRequestToAllTabs(getCompletionKeysRequest());    }    /* @@ -574,7 +591,11 @@          refreshedCompletionKeys = true;        } else { -        repeatFunction(this[registryEntry.command], count, 0, frameId); +        if(registryEntry.passCountToFunction){ +          this[registryEntry.command](count); +        } else { +          repeatFunction(this[registryEntry.command], count, 0, frameId); +        }        }        newKeyQueue = ""; @@ -592,10 +613,8 @@      // If we haven't sent the completion keys piggybacked on executePageCommand,      // send them by themselves. -    if (!refreshedCompletionKeys) -    { -      var port = chrome.tabs.connect(tabId, { name: "refreshCompletionKeys" }); -      port.postMessage({ completionKeys: generateCompletionKeys(newKeyQueue) }); +    if (!refreshedCompletionKeys) { +      chrome.tabs.sendRequest(tabId, getCompletionKeysRequest(), null);      }      return newKeyQueue; @@ -648,7 +667,7 @@      if (!framesForTab[sender.tab.id])        framesForTab[sender.tab.id] = { frames: [] }; -    if (request.top) { +    if (request.is_top) {        focusedFrame = request.frameId;        framesForTab[sender.tab.id].total = request.total;      } @@ -681,25 +700,29 @@      focusedFrame = request.frameId;    } -  function nextFrame(callback, frameId) { +  function nextFrame(count) {      chrome.tabs.getSelected(null, function(tab) { -      var index;        var frames = framesForTab[tab.id].frames; +      var curr_index = getCurrFrameIndex(frames); -      for (index=0; index < frames.length; index++) { -        if (frames[index].id == focusedFrame) -            break; -      } - -      if (index >= frames.length-1) -        index = 0; -      else -        index++; +      // TODO: Skip the "top" frame (which doesn't actually have a <frame> tag), +      // since it exists only to contain the other frames. +      var new_index = (curr_index + count) % frames.length; -      chrome.tabs.sendRequest(tab.id, { name: "focusFrame", frameId: frames[index].id, highlight: true }); +      chrome.tabs.sendRequest(tab.id, { name: "focusFrame", frameId: frames[new_index].id, highlight: true });      });    } +  function getCurrFrameIndex(frames) { +    var index; +    for (index=0; index < frames.length; index++) { +      if (frames[index].id == focusedFrame) +          break; +    } +    return index; +  } + +    function init() {      clearKeyMappingsAndSetDefaults(); diff --git a/bookmarks.js b/bookmarks.js new file mode 100644 index 00000000..9056c731 --- /dev/null +++ b/bookmarks.js @@ -0,0 +1,131 @@ +function activateBookmarkFindModeToOpenInNewTab() { +  BookmarkMode.openInNewTab(true); +  BookmarkMode.enable(); +} + +function activateBookmarkFindMode() { +  BookmarkMode.openInNewTab(false); +  BookmarkMode.enable(); +} + +(function() { +  // so when they let go of shift after hitting capital "B" it won't +  // untoggle it +  var shiftWasPressedWhileToggled = false; + +  var BookmarkMode = { +    isEnabled: function() { +      return this.enabled; +    }, +    openInNewTab: function(newTab) { +      this.newTab = newTab; +    }, +    invertNewTabSetting: function() { +      this.newTab = !this.newTab; +      if(this.isEnabled()) { +        this.renderHUD(); +      } +    }, +    enable: function() { +      this.enabled = true; + +      if(!this.initialized) { +        initialize.call(this); +      } + +      handlerStack.push({ +        keydown: this.onKeydown, +        keyup: this.onKeyup +      }); + +      this.renderHUD(); +      this.completionDialog.show(); +    }, +    disable: function() { +      this.enabled = false; +      this.completionDialog.hide(); +      handlerStack.pop(); +      HUD.hide(); +    }, +    renderHUD: function() { +      if (this.newTab) +        HUD.show("Open bookmark in new tab"); +      else +        HUD.show("Open bookmark in current tab"); +    } + +  } + +  // private method +  var initialize = function() { +    var self = this; +    self.initialized = true; + +    self.completionDialog = new CompletionDialog({ +      source: findBookmarks, + +      onSelect: function(selection) { +        var url = selection.url; +        var isABookmarklet = function(url) { return url.indexOf("javascript:") === 0; } + +        if (!self.newTab || isABookmarklet(url)) +          window.location = url; +        else +          window.open(url); + +        self.disable(); +      }, + +      renderOption: function(searchString, selection) { +        var displaytext = selection.title + " (" + selection.url + ")" +        if (displaytext.length > 70) +          displaytext = displaytext.substr(0, 70) + "..."; + +        return displaytext.split(new RegExp(searchString, "i")).join("<strong>"+searchString+"</strong>") +      }, + +      initialSearchText: "Type a bookmark name or URL" +    }) + +    self.onKeydown = function(event) { +      // shift key will toggle between new tab/same tab +      if (event.keyCode == keyCodes.shiftKey) { +        self.invertNewTabSetting(); +        shiftWasPressedWhileToggled = true; +        return; +      } + +      var keyChar = getKeyChar(event); +      if (!keyChar) +        return; + +      // TODO(philc): Ignore keys that have modifiers. +      if (isEscape(event)) +        self.disable(); + +      event.stopPropagation(); +      event.preventDefault(); +    }; + +    self.onKeyup = function(event) { +      // shift key will toggle between new tab/same tab +      if (event.keyCode == keyCodes.shiftKey && shiftWasPressedWhileToggled) { +        self.invertNewTabSetting(); +        shiftWasPressedWhileToggled = false; +      } +      event.stopPropagation(); +      event.preventDefault(); +    }; +  } + +  var findBookmarks = function(searchString, callback) { +    var port = chrome.extension.connect({ name: "getBookmarks" }) ; +    port.onMessage.addListener(function(msg) { +      callback(msg.bookmarks); +      port = null; +    }) +    port.postMessage({query:searchString}); +  }; + +  window.BookmarkMode = BookmarkMode; +}()) diff --git a/commands.js b/commands.js index be01a180..d214d2bf 100644 --- a/commands.js +++ b/commands.js @@ -111,15 +111,11 @@ function clearKeyMappingsAndSetDefaults() {      "L": "goForward",      "gu": "goUp", -    "zi": "zoomIn", -    "zo": "zoomOut", -    "z0": "zoomReset", -      "gi": "focusInput", -    "f":     "activateLinkHintsMode", -    "F":     "activateLinkHintsModeToOpenInNewTab", -    "<a-f>": "activateLinkHintsModeWithQueue", +    "f":     "linkHints.activateMode", +    "F":     "linkHints.activateModeToOpenInNewTab", +    "<a-f>": "linkHints.activateModeWithQueue",      "/": "enterFindMode",      "n": "performFind", @@ -129,16 +125,22 @@ function clearKeyMappingsAndSetDefaults() {      "]]": "goNext",      "yy": "copyCurrentUrl", +    "yf": "linkHints.activateModeToCopyLinkUrl",      "K": "nextTab",      "J": "previousTab",      "gt": "nextTab",      "gT": "previousTab", +    "g0": "firstTab", +    "g$": "lastTab",      "t": "createTab",      "x": "removeTab",      "X": "restoreTab", +    "b": "activateBookmarkFindMode", +    "B": "activateBookmarkFindModeToOpenInNewTab", +      "gf": "nextFrame"    }; @@ -166,18 +168,17 @@ var commandDescriptions = {    reload: ["Reload the page"],    toggleViewSource: ["View page source"], -  zoomIn: ["Zoom in"], -  zoomOut: ["Zoom out"], -  zoomReset: ["Reset zoom to default value"],    copyCurrentUrl: ["Copy the current URL to the clipboard"], +  'linkHints.activateModeToCopyLinkUrl': ["Copy a link URL to the clipboard"], +    enterInsertMode: ["Enter insert mode"],    focusInput: ["Focus the first (or n-th) text box on the page", { passCountToFunction: true }], -  activateLinkHintsMode: ["Open a link in the current tab"], -  activateLinkHintsModeToOpenInNewTab: ["Open a link in a new tab"], -  activateLinkHintsModeWithQueue: ["Open multiple links in a new tab"], +  'linkHints.activateMode': ["Open a link in the current tab"], +  'linkHints.activateModeToOpenInNewTab': ["Open a link in a new tab"], +  'linkHints.activateModeWithQueue': ["Open multiple links in a new tab"],    enterFindMode: ["Enter find mode"],    performFind: ["Cycle forward to the next find match"], @@ -187,8 +188,8 @@ var commandDescriptions = {    goNext: ["Follow the link labeled next or >"],    // Navigating your history -  goBack: ["Go back in history"], -  goForward: ["Go forward in history"], +  goBack: ["Go back in history", { passCountToFunction: true }], +  goForward: ["Go forward in history", { passCountToFunction: true }],    // Navigating the URL hierarchy    goUp: ["Go up the URL hierarchy", { passCountToFunction: true }], @@ -196,11 +197,16 @@ var commandDescriptions = {    // Manipulating tabs    nextTab: ["Go one tab right", { background: true }],    previousTab: ["Go one tab left", { background: true }], +  firstTab: ["Go to the first tab", { background: true }], +  lastTab: ["Go to the last tab", { background: true }],    createTab: ["Create new tab", { background: true }],    removeTab: ["Close current tab", { background: true }],    restoreTab: ["Restore closed tab", { background: true }], -  nextFrame: ["Cycle forward to the next frame on the page", { background: true }] +  activateBookmarkFindMode: ["Open a bookmark in the current tab"], +  activateBookmarkFindModeToOpenInNewTab: ["Open a bookmark in a new tab"], + +  nextFrame: ["Cycle forward to the next frame on the page", { background: true, passCountToFunction: true }]  };  for (var command in commandDescriptions) @@ -214,15 +220,16 @@ var commandGroups = {      ["scrollDown", "scrollUp", "scrollLeft", "scrollRight",       "scrollToTop", "scrollToBottom", "scrollToLeft", "scrollToRight", "scrollPageDown",       "scrollPageUp", "scrollFullPageUp", "scrollFullPageDown", -     "reload", "toggleViewSource", "zoomIn", "zoomOut", "zoomReset", "copyCurrentUrl", "goUp", +     "reload", "toggleViewSource", "copyCurrentUrl", "linkHints.activateModeToCopyLinkUrl", "goUp",       "enterInsertMode", "focusInput", -     "activateLinkHintsMode", "activateLinkHintsModeToOpenInNewTab", "activateLinkHintsModeWithQueue", +     "linkHints.activateMode", "linkHints.activateModeToOpenInNewTab", "linkHints.activateModeWithQueue", +     "activateBookmarkFindMode", "activateBookmarkFindModeToOpenInNewTab",       "goPrevious", "goNext", "nextFrame"],    findCommands: ["enterFindMode", "performFind", "performBackwardsFind"],    historyNavigation:      ["goBack", "goForward"],    tabManipulation: -    ["nextTab", "previousTab", "createTab", "removeTab", "restoreTab"], +    ["nextTab", "previousTab", "firstTab", "lastTab", "createTab", "removeTab", "restoreTab"],    misc:      ["showHelp"]  }; @@ -232,5 +239,5 @@ var commandGroups = {  // from Vimium will uncover these gems.  var advancedCommands = [      "scrollToLeft", "scrollToRight", -    "zoomReset", "goUp", "focusInput", "activateLinkHintsModeWithQueue", +    "goUp", "focusInput", "linkHints.activateModeWithQueue",      "goPrevious", "goNext"]; diff --git a/completionDialog.js b/completionDialog.js new file mode 100644 index 00000000..fae034fd --- /dev/null +++ b/completionDialog.js @@ -0,0 +1,180 @@ +(function(window, document) { + +  var CompletionDialog = function(options) { this.options = options; } + +  CompletionDialog.prototype = { +    show: function() { +      if (!this.isShown) { +        this.isShown=true; +        this.query = []; +        if (!this.initialized) { +          initialize.call(this); +          this.initialized = true; +        } +        handlerStack.push({ keydown: this.onKeydown }); +        render.call(this); +        clearInterval(this._tweenId); +        this.container.style.display = ""; +        this._tweenId = Tween.fade(this.container, 1.0, 150); +      } +    }, + +    hide: function() { +      if (this.isShown) { +        handlerStack.pop(); +        this.isShown = false; +        this.currentSelection = 0; +        clearInterval(this._tweenId); +        var completionContainer = this.container; +        var cssHide = function() { completionContainer.style.display = "none"; } +        this._tweenId = Tween.fade(this.container, 0, 150, cssHide); +      } +    }, + +    getDisplayElement: function() { +      if (!this.container) +        this.container = createDivInside(document.body); +      return this.container; +    }, + +    getQueryString: function() { return this.query.join(""); } +  } + +  var initialize = function() { +    var self = this; +    addCssToPage(completionCSS); + +    self.currentSelection = 0; + +    self.onKeydown = function(event) { +      var keyChar = getKeyChar(event); +      // change selection with up or Shift-Tab +      if (keyChar==="up" || (event.keyCode == 9 && event.shiftKey)) { +        if (self.currentSelection>0) { +          self.currentSelection-=1; +        } +        render.call(self,self.getQueryString(), self.completions); +      } +      // change selection with down or Tab +      else if (keyChar==="down" || (event.keyCode == 9 && !event.shiftKey)) { +        if (self.currentSelection < self.completions.length - 1) { +          self.currentSelection += 1; +        } +        render.call(self,self.getQueryString(), self.completions); +      } +      else if (event.keyCode == keyCodes.enter) { +        self.options.onSelect(self.completions[self.currentSelection]); +      } +      else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { +        if (self.query.length > 0) { +          self.query.pop(); +          self.options.source(self.getQueryString(), function(completions) { +            render.call(self, self.getQueryString(), completions); +          }) +        } +      } +      else if (keyChar!=="left" && keyChar!="right") { +        self.query.push(keyChar); +        self.options.source(self.getQueryString(), function(completions) { +          render.call(self, self.getQueryString(), completions); +        }); +      } + +      event.stopPropagation(); +      event.preventDefault(); +      return true; +    } +  } + +  var render = function(searchString, completions) { +    if (this.isShown) { +      this.searchString = searchString; +      this.completions = completions; +      var container = this.getDisplayElement(); +      clearChildren(container); + +      if (searchString === undefined) { +        this.container.className = "vimium-dialog"; +        createDivInside(container).innerHTML = this.options.initialSearchText || "Begin typing"; +      } +      else { +        this.container.className = "vimium-dialog vimium-completions"; +        var searchBar = createDivInside(container); +        searchBar.innerHTML=searchString; +        searchBar.className="vimium-searchBar"; + +        searchResults = createDivInside(container); +        searchResults.className="vimium-searchResults"; +        if (completions.length<=0) { +          var resultDiv = createDivInside(searchResults); +          resultDiv.className="vimium-noResults"; +          resultDiv.innerHTML="No results found"; +        } +        else { +          for (var i = 0; i < completions.length; i++) { +            var resultDiv = createDivInside(searchResults); +            if (i === this.currentSelection) { +              resultDiv.className="vimium-selected"; +            } +            resultDiv.innerHTML=this.options.renderOption(searchString, completions[i]); +          } +        } +      } + +      container.style.top = Math.max(0, (window.innerHeight/2-container.clientHeight/2)) + "px"; +      container.style.left = (window.innerWidth/2-container.clientWidth/2) + "px"; +    } +  }; +  var createDivInside = function(parent) { +    var element = document.createElement("div"); +    parent.appendChild(element); +    return element; +  } + +  var clearChildren = function(elem) { +    if (elem.hasChildNodes()) { +      while (elem.childNodes.length >= 1) { +        elem.removeChild(elem.firstChild); +      } +    } +  } + +  var completionCSS = ".vimium-dialog {"+ +    "position:fixed;"+ +    "background-color: #ebebeb;" + +    "z-index: 99999998;" + +    "border: 1px solid #b3b3b3;" + +    "font-size: 12px;" + +    "text-align:left;"+ +    "color: black;" + +    "padding:10px;"+ +    "border-radius: 4px;" + +    "font-family: Lucida Grande, Arial, Sans;" + +    "}"+ +    ".vimium-completions {"+ +    "width:400px;"+ +    "}"+ +    ".vimium-completions .vimium-searchBar {"+ +    "height: 15px;"+ +    "border-bottom: 1px solid #b3b3b3;"+ +    "}"+ +    ".vimium-completions .vimium-searchResults {"+ +    "}"+ +    ".vimium-completions .vimium-searchResults .vimium-selected{"+ +    "background-color:#aaa;"+ +    "border-radius: 4px;" + +    "}"+ +    ".vimium-completions div{"+ +    "padding:4px;"+ +    "}"+ +    ".vimium-completions div strong{"+ +    "color: black;" + +    "font-weight:bold;"+ +    "}"+ +    ".vimium-completions .vimium-noResults{"+ +    "color:#555;"+ +    "}"; + +  window.CompletionDialog = CompletionDialog; + +}(window, document)) diff --git a/helpDialog.html b/helpDialog.html index 51ff1692..5ded51f2 100644 --- a/helpDialog.html +++ b/helpDialog.html @@ -28,6 +28,22 @@        top:50px;        -webkit-box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 6px;        z-index:99999998; +      overflow-y: scroll; +    } +    @media screen and (max-height: 600px) { +        #vimiumHelpDialog { +            height: 430px; +        } +    } +    @media screen and (max-height: 500px) { +        #vimiumHelpDialog { +            height: 330px; +        } +    } +    @media screen and (max-height: 400px) { +        #vimiumHelpDialog { +            height: 230px; +        }      }      #vimiumHelpDialog a { color:blue; }      #vimiumTitle, #vimiumTitle * { font-size:20px; } @@ -141,7 +157,6 @@          this.dialogElement.getElementsByClassName("toggleAdvancedCommands")[0].addEventListener("click",             VimiumHelpDialog.toggleAdvancedCommands, false);          this.showAdvancedCommands(this.advancedCommandsVisible); -        this.centerDialog();        },        /* @@ -153,7 +168,6 @@          chrome.extension.sendRequest({ handler: "saveHelpDialogSettings",              showAdvancedCommands: VimiumHelpDialog.advancedCommandsVisible });          VimiumHelpDialog.showAdvancedCommands(VimiumHelpDialog.advancedCommandsVisible); -        VimiumHelpDialog.centerDialog();        },        showAdvancedCommands: function(visible) { @@ -162,14 +176,7 @@          var advanced = VimiumHelpDialog.dialogElement.getElementsByClassName("advanced");          for (var i = 0; i < advanced.length; i++)            advanced[i].style.display = (visible ? "table-row" : "none"); -      }, - -      centerDialog: function() { -        var zoomFactor = currentZoomLevel / 100.0; -        this.dialogElement.style.top = Math.max( -            (window.innerHeight - this.dialogElement.clientHeight * zoomFactor) / 2.0, -            20) / zoomFactor + "px"; -        } +      }      };      VimiumHelpDialog.init(); diff --git a/lib/keyboardUtils.js b/lib/keyboardUtils.js index fe3dcd59..98725d95 100644 --- a/lib/keyboardUtils.js +++ b/lib/keyboardUtils.js @@ -44,7 +44,7 @@ function getKeyChar(event) {      // https://bugs.webkit.org/show_bug.cgi?id=19906 for more details.      if ((platform == "Windows" || platform == "Linux") && keyIdentifierCorrectionMap[keyIdentifier]) {        correctedIdentifiers = keyIdentifierCorrectionMap[keyIdentifier]; -      keyIdentifier = event.shiftKey ? correctedIdentifiers[0] : correctedIdentifiers[1]; +      keyIdentifier = event.shiftKey ? correctedIdentifiers[1] : correctedIdentifiers[0];      }      var unicodeKeyInHex = "0x" + keyIdentifier.substring(2);      return String.fromCharCode(parseInt(unicodeKeyInHex)).toLowerCase(); diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 00000000..ef961833 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,33 @@ +var utils = { +  /* +   * 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); +  }, + +  /* +   * Takes an array of XPath selectors, adds the necessary namespaces (currently only XHTML), and applies them +   * to the document root. The namespaceResolver in evaluateXPath should be kept in sync with the namespaces +   * here. +   */ +  makeXPath: function(elementArray) { +    var xpath = []; +    for (var i in elementArray) +      xpath.push("//" + elementArray[i], "//xhtml:" + elementArray[i]); +    return xpath.join(" | "); +  }, + +  evaluateXPath: function(xpath, resultType) { +    function namespaceResolver(namespace) { +      return namespace == "xhtml" ? "http://www.w3.org/1999/xhtml" : null; +    } +    return document.evaluate(xpath, document.documentElement, namespaceResolver, resultType, null); +  }, +}; diff --git a/linkHints.js b/linkHints.js index de476e36..d37d2d7c 100644 --- a/linkHints.js +++ b/linkHints.js @@ -1,337 +1,605 @@  /* - * 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"); +var linkHints = { +  hintMarkers: [], +  hintMarkerContainingDiv: null, +  // The characters that were typed in while in "link hints" mode. +  shouldOpenInNewTab: false, +  shouldOpenWithQueue: false, +  // flag for copying link instead of opening +  shouldCopyLinkUrl: 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, +  // Handle the link hinting marker generation and matching. Must be initialized after settings have been +  // loaded, so that we can retrieve the option setting. +  markerMatcher: undefined, + +  /* +   * To be called after linkHints has been generated from linkHintsBase. +   */ +  init: function() { +    this.onKeyDownInMode = this.onKeyDownInMode.bind(this); +    this.onKeyPressInMode = this.onKeyPressInMode.bind(this); +    this.onKeyUpInMode = this.onKeyUpInMode.bind(this); +    this.markerMatcher = settings.get('filterLinkHints') == "true" ? filterHints : alphabetHints; +  }, + +  /* +   * Generate an XPath describing what a clickable element is. +   * The final expression will be something like "//button | //xhtml:button | ..." +   */ +  clickableElementsXPath: utils.makeXPath(["a", "area[@href]", "textarea", "button", "select","input[not(@type='hidden')]", +                             "*[@onclick or @tabindex or @role='link' or @role='button']"]), + +  // We need this as a top-level function because our command system doesn't yet support arguments. +  activateModeToOpenInNewTab: function() { this.activateMode(true, false, false); }, + +  activateModeToCopyLinkUrl: function() { this.activateMode(false, false, true); }, + +  activateModeWithQueue: function() { this.activateMode(true, true, false); }, + +  activateMode: function(openInNewTab, withQueue, copyLinkUrl) { +    if (!this.cssAdded) +      addCssToPage(linkHintCss); // linkHintCss is declared by vimiumFrontend.js +    this.linkHintCssAdded = true; +    this.setOpenLinkMode(openInNewTab, withQueue, copyLinkUrl); +    this.buildLinkHints(); +    handlerStack.push({ // modeKeyHandler is declared by vimiumFrontend.js +      keydown: this.onKeyDownInMode, +      keypress: this.onKeyPressInMode, +      keyup: this.onKeyUpInMode +    }); + +    this.openLinkModeToggle = false; +  }, + +  setOpenLinkMode: function(openInNewTab, withQueue, copyLinkUrl) { +    this.shouldOpenInNewTab = openInNewTab; +    this.shouldOpenWithQueue = withQueue; +    this.shouldCopyLinkUrl = copyLinkUrl; +    if (this.shouldCopyLinkUrl) { +      HUD.show("Copy link URL to Clipboard"); +    } else 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(); +    this.hintMarkers = this.markerMatcher.getHintMarkers(visibleElements); + +    // Note(philc): Append these markers as top level children instead of as child nodes to the link itself, +    // because some clickable elements cannot contain children, e.g. submit buttons. This has the caveat +    // that if you scroll the page and the link has position=fixed, the marker will not stay fixed. +    // Also note that adding these nodes to document.body all at once is significantly faster than one-by-one. +    this.hintMarkerContainingDiv = document.createElement("div"); +    this.hintMarkerContainingDiv.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 -      HUD.show("Open link in current tab"); -  } -} - -/* - * Builds and displays link hints for every visible clickable item on the page. - */ -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, count = visibleElements.length; i < count; 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. - */ -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, count = resultSet.snapshotLength; i < count; 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, childrenCount = element.children.length; j < childrenCount; 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; -          } -        } +      this.deactivateMode(); +  }, + +  /* +   * Returns all clickable elements that are not hidden and are in the current viewport. +   * We prune invisible elements partly for performance reasons, but moreso it's to decrease the number +   * of digits needed to enumerate all of the links on screen. +   */ +  getVisibleClickableElements: function() { +    var resultSet = utils.evaluateXPath(this.clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE); + +    var visibleElements = []; + +    // Find all visible clickable elements. +    for (var i = 0, count = resultSet.snapshotLength; i < count; i++) { +      var element = resultSet.snapshotItem(i); +      var clientRect = this.getVisibleClientRect(element, clientRect); +      if (clientRect !== null) +        visibleElements.push({element: element, rect: clientRect}); + +      if (element.localName === "area") { +        var map = element.parentElement; +        var img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']"); +        var clientRect = img.getClientRects()[0]; +        var c = element.coords.split(/,/); +        var coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)]; +        var rect = { +          top: clientRect.top + coords[1], +          left: clientRect.left + coords[0], +          right: clientRect.left + coords[2], +          bottom: clientRect.top + coords[3], +          width: coords[2] - coords[0], +          height: coords[3] - coords[1] +        }; + +        visibleElements.push({element: element, rect: rect});        }      } -  } -  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; -  } +    return visibleElements; +  }, + +  /** +   * Returns the first visible clientRect of an element if it exists. Otherwise it returns null. +   */ +  getVisibleClientRect: function(element) { +    // Note: this call will be expensive if we modify the DOM in between calls. +    var clientRects = element.getClientRects(); + +    for (var i = 0, len = clientRects.length; i < len; i++) { +      // Exclude links which have just a few pixels on screen, because the link hints won't show for them +      // anyway. +      if (clientRects[i].top < 0 || clientRects[i].top >= window.innerHeight - 4 || +          clientRects[i].left < 0 || clientRects[i].left  >= window.innerWidth - 4) +        continue; + +      if (clientRects[i].width < 3 || clientRects[i].height < 3) +        continue; + +      // eliminate invisible elements (see test_harnesses/visibility_test.html) +      var computedStyle = window.getComputedStyle(element, null); +      if (computedStyle.getPropertyValue('visibility') != 'visible' || +          computedStyle.getPropertyValue('display') == 'none') +        continue; + +      // If the link has zero dimensions, it may be wrapping visible +      // but floated elements. Check for this. +      if (clientRects[i].width == 0 || clientRects[i].height == 0) { +        for (var j = 0, childrenCount = element.children.length; j < childrenCount; j++) { +          var computedStyle = window.getComputedStyle(element.children[j], null); +          // Ignore child elements which are not floated and not absolutely positioned for parent elements with zero width/height +          if (computedStyle.getPropertyValue('float') == 'none' && computedStyle.getPropertyValue('position') != 'absolute') +            continue; +          var childClientRect = this.getVisibleClientRect(element.children[j]); +          if (childClientRect === null) +            continue; +          return childClientRect; +        } +      } -  var keyChar = getKeyChar(event); -  if (!keyChar) -    return; +      return clientRects[i]; +    }; +    return null; +  }, + +  /* +   * Handles shift and esc keys. The other keys are passed to markerMatcher.matchHintsByKey. +   */ +  onKeyDownInMode: function(event) { +    if (this.delayMode) +      return; + +    if (event.keyCode == keyCodes.shiftKey && !this.openLinkModeToggle) { +      // Toggle whether to open link in a new or current tab. +      this.setOpenLinkMode(!this.shouldOpenInNewTab, this.shouldOpenWithQueue, false); +      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.markerMatcher.matchHintsByKey(event, this.hintMarkers); +      var linksMatched = keyResult.linksMatched; +      var delay = keyResult.delay !== undefined ? keyResult.delay : 0; +      if (linksMatched.length == 0) { +        this.deactivateMode(); +      } else if (linksMatched.length == 1) { +        this.activateLink(linksMatched[0].clickableItem, delay); +      } else { +        for (var i in this.hintMarkers) +          this.hideMarker(this.hintMarkers[i]); +        for (var i in linksMatched) +          this.showMarker(linksMatched[i], this.markerMatcher.hintKeystrokeQueue.length); +      }      } -  } 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(); -} +  onKeyPressInMode: function(event) { +    return !this.delayMode; +  }, -/* - * 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 (this.delayMode) +      return; + +    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, false); +      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); +      this.deactivateMode(delay, function() { that.delayMode = false; });      } 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.shouldCopyLinkUrl) { +        this.copyLinkUrl(matchedLink); +        this.deactivateMode(delay, function() { that.delayMode = false; }); +      } 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"; +  }, + +  copyLinkUrl: function(link) { +    chrome.extension.sendRequest({handler: 'copyLinkUrl', data: link.href}); +  }, + +  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, count = linkMarker.childNodes.length; j < count; 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); + +    // TODO(int3): do this for @role='link' and similar elements as well +    var nodeName = link.nodeName.toLowerCase(); +    if (nodeName == 'a' || nodeName == 'button') +      link.blur(); +  }, + +  /* +   * 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.markerMatcher.deactivate) +        that.markerMatcher.deactivate(); +      if (that.hintMarkerContainingDiv) +        that.hintMarkerContainingDiv.parentNode.removeChild(that.hintMarkerContainingDiv); +      that.hintMarkerContainingDiv = null; +      that.hintMarkers = []; +      handlerStack.pop(); +      HUD.hide(); +    } +    // we invoke the deactivate() function directly instead of using setTimeout(callback, 0) so that +    // deactivateMode can be tested synchronously +    if (!delay) { +      deactivate(); +      if (callback) callback(); +    } else { +      setTimeout(function() { deactivate(); if (callback) callback(); }, delay); +    } +  }, + +}; + +var alphabetHints = { +  hintKeystrokeQueue: [], +  logXOfBase: function(x, base) { return Math.log(x) / Math.log(base); }, + +  getHintMarkers: function(visibleElements) { +    //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(this.logXOfBase( +          visibleElements.length, settings.get('linkHintCharacters').length)); +    var hintMarkers = []; + +    for (var i = 0, count = visibleElements.length; i < count; i++) { +      var hintString = this.numberToHintString(i, digitsNeeded); +      var marker = hintUtils.createMarkerFor(visibleElements[i]); +      marker.innerHTML = hintUtils.spanWrap(hintString); +      marker.setAttribute("hintString", hintString); +      hintMarkers.push(marker); +    } -/* - * 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"; -} +    return hintMarkers; +  }, +  /* +   * 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. +    // Note: the loop body changes hintString.length, so the original length must be cached! +    var hintStringLength = hintString.length; +    for (var i = 0; i < numHintDigits - hintStringLength; i++) +      hintString.unshift(settings.get('linkHintCharacters')[0]); + +    // Reversing the hint string has the advantage of making the link hints +    // appear to spread out after the first key is hit. This is helpful on a +    // page that has http links that are close to each other where link hints +    // of 2 characters or more occlude each other. +    hintString.reverse(); +    return hintString.join(""); +  }, + +  matchHintsByKey: function(event, hintMarkers) { +    var linksMatched = hintMarkers; +    var keyChar = getKeyChar(event); +    if (!keyChar) +      return { 'linksMatched': linksMatched }; + +    if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { +      if (this.hintKeystrokeQueue.length == 0) { +        var linksMatched = []; +      } else { +        this.hintKeystrokeQueue.pop(); +        var matchString = this.hintKeystrokeQueue.join(""); +        var linksMatched = linksMatched.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 = linksMatched.filter(function(linkMarker) { +        return linkMarker.getAttribute("hintString").indexOf(matchString) == 0; +      }); +    } +    return { 'linksMatched': linksMatched }; +  }, -/* - * 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 = ""; -      var childNodes = linkMarker.childNodes; -      for (var j = 0, childNodesCount = childNodes.length; j < childNodesCount; j++) -        childNodes[j].className = (j >= searchString.length) ? "" : "matchingCharacter"; -      linksMatched.push(linkMarker.clickableItem); +  deactivate: function() { +    this.hintKeystrokeQueue = []; +  } + +}; + +var filterHints = { +  hintKeystrokeQueue: [], +  linkTextKeystrokeQueue: [], +  labelMap: {}, + +  /* +   * Generate a map of input element => label +   */ +  generateLabelMap: function() { +    var labels = document.querySelectorAll("label"); +    for (var i = 0, count = labels.length; i < count; i++) { +      var forElement = labels[i].getAttribute("for"); +      if (forElement) { +        var labelText = labels[i].textContent.trim(); +        // remove trailing : commonly found in labels +        if (labelText[labelText.length-1] == ":") +          labelText = labelText.substr(0, labelText.length-1); +        this.labelMap[forElement] = labelText; +      } +    } +  }, + +  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; -} +    linkText = linkText.trim().toLowerCase(); +    marker.setAttribute("hintString", hintString); +    marker.innerHTML = hintUtils.spanWrap(hintString + (showLinkText ? ": " + linkText : "")); +    marker.setAttribute("linkText", linkText); +  }, + +  getHintMarkers: function(visibleElements) { +    this.generateLabelMap(); +    var hintMarkers = []; +    for (var i = 0, count = visibleElements.length; i < count; i++) { +      var marker = hintUtils.createMarkerFor(visibleElements[i]); +      this.setMarkerAttributes(marker, i); +      hintMarkers.push(marker); +    } +    return hintMarkers; +  }, + +  matchHintsByKey: function(event, hintMarkers) { +    var linksMatched = hintMarkers; +    var delay = 0; +    var keyChar = getKeyChar(event); + +    if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) { +      // backspace clears hint key queue first, then acts on link text key queue +      if (this.hintKeystrokeQueue.pop()) +        linksMatched = this.filterLinkHints(linksMatched); +      else if (this.linkTextKeystrokeQueue.pop()) +        linksMatched = this.filterLinkHints(linksMatched); +      else // both queues are empty. exit hinting mode +        linksMatched = []; +    } else if (event.keyCode == keyCodes.enter) { +        // activate the lowest-numbered link hint that is visible +        for (var i = 0, count = linksMatched.length; i < count; i++) +          if (linksMatched[i].style.display  != 'none') { +            linksMatched = [ linksMatched[i] ]; +            break; +          } +    } else if (keyChar) { +      var matchString; +      if (/[0-9]/.test(keyChar)) { +        this.hintKeystrokeQueue.push(keyChar); +        matchString = this.hintKeystrokeQueue.join(""); +        linksMatched = linksMatched.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); +        linksMatched = this.filterLinkHints(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. -  for (var i = 0, count = numHintDigits - hintString.length; i < count; i++) -    hintString.unshift(settings.linkHintCharacters[0]); -  return hintString.join(""); -} - -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); -} - -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(); -} - -function resetLinkHintsMode() { -  deactivateLinkHintsMode(); -  activateLinkHintsModeWithQueue(); -} +      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(hintMarkers) { +    var linksMatched = []; +    var linkSearchString = this.linkTextKeystrokeQueue.join(""); + +    for (var i = 0; i < hintMarkers.length; i++) { +      var linkMarker = 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; +  }, -/* - * 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; -} +  deactivate: function(delay, callback) { +    this.hintKeystrokeQueue = []; +    this.linkTextKeystrokeQueue = []; +    this.labelMap = {}; +  } + +}; + +var hintUtils = { +  /* +   * Make each hint character a span, so that we can highlight the typed characters as you type them. +   */ +  spanWrap: function(hintString) { +    var innerHTML = []; +    for (var i = 0; i < hintString.length; i++) +      innerHTML.push("<span>" + hintString[i].toUpperCase() + "</span>"); +    return innerHTML.join(""); +  }, + +  /* +   * Creates a link marker for the given link. +   */ +  createMarkerFor: function(link) { +    var marker = document.createElement("div"); +    marker.className = "internalVimiumHintMarker vimiumHintMarker"; +    marker.clickableItem = link.element; + +    var clientRect = link.rect; +    marker.style.left = clientRect.left + window.scrollX + "px"; +    marker.style.top = clientRect.top  + window.scrollY  + "px"; + +    return marker; +  } +}; diff --git a/manifest.json b/manifest.json index 38c21b2d..a79fd90c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@  {    "name": "Vimium", -  "version": "1.26", +  "version": "1.30",    "description": "The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.",    "icons": {  "16": "icons/icon16.png",                "48": "icons/icon48.png", @@ -9,16 +9,20 @@    "options_page": "options.html",    "permissions": [      "tabs", +    "bookmarks",      "http://*/*",      "https://*/*"    ],    "content_scripts": [      {        "matches": ["<all_urls>"], -      "js": ["lib/keyboardUtils.js", +      "js": ["lib/utils.js", +             "lib/keyboardUtils.js",               "lib/clipboard.js",               "linkHints.js", -             "vimiumFrontend.js" +             "vimiumFrontend.js", +             "completionDialog.js", +             "bookmarks.js"              ],        "run_at": "document_start",        "all_frames": true diff --git a/options.html b/options.html index 627560f8..8dfd23e8 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> @@ -17,12 +18,13 @@          border:1px solid red;        }        .example { -        font-size:80%; +        font-size: 12px;          color:#555;          margin-left:20px;        }        .caption {          margin-right:10px; +        min-width: 130px;        }        td {          padding:5px 0; @@ -70,15 +72,20 @@        tr.advancedOption {          display:none;        } - +      input:read-only { +        background-color: #eee; +        color: #666; +      } +      /* Boolean options have a tighter form representation than text options. */ +      td.booleanOption { font-size: 12px; }      </style>    <script type="text/javascript">    $ = function(id) { return document.getElementById(id); };    var defaultSettings = chrome.extension.getBackgroundPage().defaultSettings; -  var editableFields = ["scrollStepSize", "defaultZoomLevel", "excludedUrls", "linkHintCharacters", -                        "userDefinedLinkHintCss", "keyMappings", "previousPatterns", "nextPatterns"]; +  var editableFields = ["scrollStepSize", "excludedUrls", "linkHintCharacters", "userDefinedLinkHintCss", +                        "keyMappings", "filterLinkHints", "previousPatterns", "nextPatterns"];    var canBeEmptyFields = ["excludedUrls", "keyMappings", "userDefinedLinkHintCss"]; @@ -93,8 +100,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 () {        showHelpDialog( @@ -107,6 +119,10 @@        enableSaveButton();    } +  function onDataLoaded() { +    $("linkHintCharacters").readOnly = $("filterLinkHints").checked; +  } +    function enableSaveButton() { $("saveOptions").removeAttribute("disabled"); }    // Saves options to localStorage. @@ -115,7 +131,16 @@      // 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() : ""; @@ -142,20 +167,33 @@      for (var i = 0; i < editableFields.length; i++) {        // If it's null or undefined, let's go to the default. We want to allow empty strings in certain cases.        if (localStorage[editableFields[i]] != "" && !localStorage[editableFields[i]]) { -        $(editableFields[i]).value = defaultSettings[editableFields[i]] || ""; +        var val = defaultSettings[editableFields[i]] || "";        } else { -        $(editableFields[i]).value = localStorage[editableFields[i]]; +	    var val = localStorage[editableFields[i]];        } -      $(editableFields[i]).setAttribute("savedValue", $(editableFields[i]).value); +	  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++) @@ -176,12 +214,6 @@          </td>        </tr>        <tr> -        <td><span class="caption">Default zoom level</span></td> -        <td> -          <input id="defaultZoomLevel" type="text" value="100" style="width:50px" />% -        </td> -      </tr> -      <tr>          <td colspan="3">              Excluded URLs<br/>              <div class="help"> @@ -200,7 +232,7 @@          </td>        </tr>        <tr class="advancedOption"> -        <td class="caption">Key mappings</td> +        <td class="caption">Custom key<br/>mappings</td>          <td id="mappingsHelp" verticalAlign="top">            <div class="help">              <div class="example"> @@ -244,11 +276,26 @@          </td>        </tr>        <tr class="advancedOption"> +        <td class="caption"></td> +        <td verticalAlign="top" class="booleanOption"> +          <div class="help"> +            <div class="example"> +              After typing "F" to enter link hinting mode, this option lets you type the text of a link +              to select it. +            </div> +          </div> +          <label> +            <input id="filterLinkHints" type="checkbox"/> +            Use the link's name and numbers for link hint filtering +          </label> +        </td> +      </tr> +      <tr class="advancedOption">          <td class="caption">Previous Patterns</td>          <td verticalAlign="top">              <div class="help">                <div class="example"> -                The Patterns split by ','. +                  Vimium will match against these patterns to navigate to a 'previous' page.                </div>              </div>              <input id="previousPatterns" type="text" style="width:320px" /> @@ -259,7 +306,7 @@          <td verticalAlign="top">              <div class="help">                <div class="example"> -                The Patterns split by ','. +                  Vimium will match against these patterns to navigate to a 'next' page.                </div>              </div>              <input id="nextPatterns" type="text" style="width:320px" /> diff --git a/test_harnesses/automated.html b/test_harnesses/automated.html new file mode 100644 index 00000000..9f1b8007 --- /dev/null +++ b/test_harnesses/automated.html @@ -0,0 +1,252 @@ +<!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; +            linkHints.markerMatcher = alphabetHints; +          }), + +          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"); +          linkHints.markerMatcher = alphabetHints; +          // 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"); +          linkHints.markerMatcher = filterHints; +        }), + +        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/iframe.html b/test_harnesses/iframe.html index 1de9b75f..750ad32d 100644 --- a/test_harnesses/iframe.html +++ b/test_harnesses/iframe.html @@ -21,6 +21,6 @@  </head>  <body>    <h2>IFrame test page</h2> -  <iframe src="http://www.google.com"></iframe> +  <iframe src="http://www.bing.com"></iframe>  </body>  </html>
\ No newline at end of file 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 0e8bdbb5..a9dc1d37 100644 --- a/vimiumFrontend.js +++ b/vimiumFrontend.js @@ -1,41 +1,69 @@  /*   * This content script takes input from its webpage and executes commands locally on behalf of the background - * page. It must be run prior to domReady so that we perform some operations very early, like setting - * 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". + * page. It must be run prior to domReady so that we perform some operations very early. 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", "previousPatterns", "nextPatterns"]; -  var getCurrentUrlHandlers = []; // function(url) -var insertMode = false; +var insertModeLock = null;  var findMode = false;  var findModeQuery = "";  var findModeQueryHasResults = false;  var isShowingHelpDialog = false; +var handlerStack = [];  var keyPort;  var settingPort; -var saveZoomLevelPort;  // Users can disable Vimium on URL patterns via the settings page.  var isEnabledForUrl = true;  // The user's operating system.  var currentCompletionKeys; +var validFirstKeys;  var linkHintCss; -// TODO(philc): This should be pulled from the extension's storage when the page loads. -var currentZoomLevel = 100; -  // The types in <input type="..."> that we consider for focusInput command. Right now this is recalculated in  // each content script. Alternatively we could calculate it once in the background page and use a request to  // fetch it each time.  //  // Should we include the HTML5 date pickers here? -var textInputTypes = ["text", "search", "email", "url", "number"]; +  // The corresponding XPath for such elements. -var textInputXPath = '//input[' + -                     textInputTypes.map(function (type) { return '@type="' + type + '"'; }).join(" or ") + -                     ' or not(@type)]'; +var textInputXPath = (function() { +  var textInputTypes = ["text", "search", "email", "url", "number"]; +  var inputElements = ["input[" + +    textInputTypes.map(function (type) { return '@type="' + type + '"'; }).join(" or ") + "or not(@type)]", +    "textarea"]; +  return utils.makeXPath(inputElements); +})(); + +var settings = { +  values: {}, +  loadedValues: 0, +  valuesToLoad: ["scrollStepSize", "linkHintCharacters", "filterLinkHints", "previousPatterns", "nextPatterns"], + +  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 () { +    linkHints.init(); +  } +};  /*   * Give this frame a unique id. @@ -43,27 +71,16 @@ var textInputXPath = '//input[' +  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; } +var googleRegex = /:\/\/[^/]*google[^/]+/;  /* - * Complete initialization work that sould be done prior to DOMReady, like setting the page's zoom level. + * Complete initialization work that sould be done prior to DOMReady.   */  function initializePreDomReady() { -  for (var i in settingsToLoad) { getSetting(settingsToLoad[i]); } +  settings.load();    checkIfEnabledForUrl(); -  var getZoomLevelPort = chrome.extension.connect({ name: "getZoomLevel" }); -  if (window.self == window.parent) -    getZoomLevelPort.postMessage({ domain: window.location.host }); -    chrome.extension.sendRequest({handler: "getLinkHintCss"}, function (response) {      linkHintCss = response.linkHintCss;    }); @@ -74,35 +91,36 @@ function initializePreDomReady() {    keyPort = chrome.extension.connect({ name: "keyDown" });    chrome.extension.onRequest.addListener(function(request, sender, sendResponse) { -    if (request.name == "hideUpgradeNotification") +    if (request.name == "hideUpgradeNotification") {        HUD.hideUpgradeNotification(); -    else if (request.name == "showUpgradeNotification" && isEnabledForUrl) +    } else if (request.name == "showUpgradeNotification" && isEnabledForUrl) {        HUD.showUpgradeNotification(request.version); -    else if (request.name == "showHelpDialog") +    } else if (request.name == "showHelpDialog") {        if (isShowingHelpDialog)          hideHelpDialog();        else          showHelpDialog(request.dialogHtml, request.frameId); -    else if (request.name == "focusFrame") -      if(frameId == request.frameId) +    } else if (request.name == "focusFrame") { +      if (frameId == request.frameId)          focusThisFrame(request.highlight); -    else if (request.name == "refreshCompletionKeys") -      refreshCompletionKeys(request.completionKeys); +    } else if (request.name == "refreshCompletionKeys") { +      refreshCompletionKeys(request); +    }      sendResponse({}); // Free up the resources used by this open connection.    });    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); }            }          } -        refreshCompletionKeys(args.completionKeys); +        refreshCompletionKeys(args);        });      }      else if (port.name == "getScrollPosition") { @@ -122,14 +140,8 @@ function initializePreDomReady() {        port.onMessage.addListener(function(args) {          if (getCurrentUrlHandlers.length > 0) { getCurrentUrlHandlers.pop()(args.url); }        }); -    } else if (port.name == "returnZoomLevel") { -      port.onMessage.addListener(function(args) { -        currentZoomLevel = args.zoomLevel; -        if (isEnabledForUrl) -          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); @@ -144,6 +156,7 @@ function initializePreDomReady() {  function initializeWhenEnabled() {    document.addEventListener("keydown", onKeydown, true);    document.addEventListener("keypress", onKeypress, true); +  document.addEventListener("keyup", onKeyup, true);    document.addEventListener("focus", onFocusCapturePhase, true);    document.addEventListener("blur", onBlurCapturePhase, true);    enterInsertModeIfElementIsFocused(); @@ -183,100 +196,59 @@ function initializeOnDomReady() {  };  // This is a little hacky but sometimes the size wasn't available on domReady? -function registerFrameIfSizeAvailable (top) { +function registerFrameIfSizeAvailable (is_top) {    if (innerWidth != undefined && innerWidth != 0 && innerHeight != undefined && innerHeight != 0)      chrome.extension.sendRequest({ handler: "registerFrame", frameId: frameId, -        area: innerWidth * innerHeight, top: top, total: frames.length + 1 }); +        area: innerWidth * innerHeight, is_top: is_top, total: frames.length + 1 });    else -    setTimeout(function () { registerFrameIfSizeAvailable(top); }, 100); +    setTimeout(function () { registerFrameIfSizeAvailable(is_top); }, 100);  }  /* - * Checks the currently focused element of the document and will enter insert mode if that element is focusable. + * Enters insert mode if the currently focused element in the DOM is focusable.   */  function enterInsertModeIfElementIsFocused() { -  // Enter insert mode automatically if there's already a text box focused.    if (document.activeElement && isEditable(document.activeElement)) -    enterInsertMode(); -} - -/* - * Asks the background page to persist the zoom level for the given domain to localStorage. - */ -function saveZoomLevel(domain, zoomLevel) { -  if (!saveZoomLevelPort) -    saveZoomLevelPort = chrome.extension.connect({ name: "saveZoomLevel" }); -  saveZoomLevelPort.postMessage({ domain: domain, zoomLevel: zoomLevel }); -} - -/* - * Zoom in increments of 20%; this matches chrome's CMD+ and CMD- keystrokes. - * Set the zoom style on documentElement because document.body does not exist pre-page load. - */ -function setPageZoomLevel(zoomLevel, showUINotification) { -  document.documentElement.style.zoom = zoomLevel + "%"; -  if (document.body) -    HUD.updatePageZoomLevel(zoomLevel); -  if (showUINotification) -    HUD.showForDuration("Zoom: " + currentZoomLevel + "%", 1000); -} - -function zoomIn() { -  currentZoomLevel += 20; -  setAndSaveZoom(); -} - -function zoomOut() { -  currentZoomLevel -= 20; -  setAndSaveZoom(); -} - -function zoomReset() { -  currentZoomLevel = 100; -  setAndSaveZoom(); -} - -function setAndSaveZoom() { -  setPageZoomLevel(currentZoomLevel, true); -  saveZoomLevel(window.location.host, currentZoomLevel); +    enterInsertModeWithoutShowingIndicator(document.activeElement);  }  function scrollToBottom() { window.scrollTo(window.pageXOffset, document.body.scrollHeight); }  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, -                                  document.documentElement, null, -                                  XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); +  var results = utils.evaluateXPath(textInputXPath, XPathResult.ORDERED_NODE_ITERATOR_TYPE);    var lastInputBox;    var i = 0;    while (i < count) { -    i += 1; -      var currentInputBox = results.iterateNext();      if (!currentInputBox) { break; } +    if (linkHints.getVisibleClientRect(currentInputBox) === null) +        continue; +      lastInputBox = currentInputBox; + +    i += 1;    }    if (lastInputBox) { lastInputBox.focus(); }  }  function reload() { window.location.reload(); } -function goBack() { history.back(); } -function goForward() { history.forward(); } +function goBack(count) { history.go(-count); } +function goForward(count) { history.go(count); }  function goUp(count) {    var url = window.location.href; @@ -308,6 +280,8 @@ function copyCurrentUrl() {    // TODO(ilya): Convert to sendRequest.    var getCurrentUrlPort = chrome.extension.connect({ name: "getCurrentTabUrl" });    getCurrentUrlPort.postMessage({}); + +	HUD.showForDuration("Yanked URL", 1000);  }  function toggleViewSourceCallback(url) { @@ -327,18 +301,18 @@ function toggleViewSourceCallback(url) {   * Note that some keys will only register keydown events and not keystroke events, e.g. ESC.   */  function onKeypress(event) { -  var keyChar = ""; - -  if (linkHintsModeActivated) +  if (!bubbleEvent('keypress', event))      return; +  var keyChar = ""; +    // Ignore modifier keys by themselves.    if (event.keyCode > 31) {      keyChar = String.fromCharCode(event.charCode);      // Enter insert mode when the user enables the native find interface.      if (keyChar == "f" && isPrimaryModifierKey(event)) { -      enterInsertMode(); +      enterInsertModeWithoutShowingIndicator();        return;      } @@ -349,7 +323,7 @@ function onKeypress(event) {          // Don't let the space scroll us if we're searching.          if (event.keyCode == keyCodes.space)            event.preventDefault(); -      } else if (!insertMode && !findMode) { +      } else if (!isInsertMode() && !findMode) {          if (currentCompletionKeys.indexOf(keyChar) != -1) {            event.preventDefault();            event.stopPropagation(); @@ -361,18 +335,32 @@ function onKeypress(event) {    }  } -function onKeydown(event) { -  var keyChar = ""; +/** + * Called whenever we receive a key event.  Each individual handler has the option to stop the event's + * propagation by returning a falsy value. + */ +function bubbleEvent(type, event) { +  for (var i = handlerStack.length-1; i >= 0; i--) { +    // We need to check for existence of handler because the last function call may have caused the release of +    // more than one handler. +    if (handlerStack[i] && handlerStack[i][type] && !handlerStack[i][type](event)) +      return false; +  } +  return true; +} -  if (linkHintsModeActivated) +function onKeydown(event) { +  if (!bubbleEvent('keydown', event))      return; +  var keyChar = ""; +    // handle modifiers being pressed.don't handle shiftKey alone (to avoid / being interpreted as ? -  if (event.metaKey && event.keyCode > 31 || event.ctrlKey && event.keyCode > 31 || event.altKey && event.keyCode > 31) { +  if (event.metaKey && event.keyCode > 31 || event.ctrlKey && event.keyCode > 31 || +      event.altKey && event.keyCode > 31) {      keyChar = getKeyChar(event); -    if (keyChar != "") // Again, ignore just modifiers. Maybe this should replace the keyCode > 31 condition. -    { +    if (keyChar != "") { // Again, ignore just modifiers. Maybe this should replace the keyCode>31 condition.        var modifiers = [];        if (event.shiftKey) @@ -392,44 +380,45 @@ function onKeydown(event) {      }    } -  if (insertMode && isEscape(event)) -  { +  if (isInsertMode() && isEscape(event)) {      // Note that we can't programmatically blur out of Flash embeds from Javascript.      if (!isEmbed(event.srcElement)) { -      // Remove focus so the user can't just get himself back into insert mode by typing in the same input box. -      if (isEditable(event.srcElement)) { event.srcElement.blur(); } +      // Remove focus so the user can't just get himself back into insert mode by typing in the same input +      // box. +      if (isEditable(event.srcElement)) +        event.srcElement.blur();        exitInsertMode(); -      // Added to prevent Google Instant from reclaiming the keystroke and putting us back into the search box. -      // TOOD(ilya): Revisit this. Not sure it's the absolute best approach. -      event.stopPropagation(); +      // Added to prevent Google Instant from reclaiming the keystroke and putting us back into the search +      // box. +      if (isGoogleSearch()) +        event.stopPropagation();      }    } -  else if (findMode) -  { -    if (isEscape(event)) +  else if (findMode) { +    if (isEscape(event)) {        exitFindMode();      // Don't let backspace take us back in history. -    else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) -    { +    } +    else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) {        handleDeleteForFindMode();        event.preventDefault();      } -    else if (event.keyCode == keyCodes.enter) +    else if (event.keyCode == keyCodes.enter) {        handleEnterForFindMode(); +    }    } -  else if (isShowingHelpDialog && isEscape(event)) -  { +  else if (isShowingHelpDialog && isEscape(event)) {      hideHelpDialog();    } -  else if (!insertMode && !findMode) { +  else if (!isInsertMode() && !findMode) {      if (keyChar) { -        if (currentCompletionKeys.indexOf(keyChar) != -1) { -            event.preventDefault(); -            event.stopPropagation(); -        } +      if (currentCompletionKeys.indexOf(keyChar) != -1) { +          event.preventDefault(); +          event.stopPropagation(); +      } -        keyPort.postMessage({keyChar:keyChar, frameId:frameId}); +      keyPort.postMessage({keyChar:keyChar, frameId:frameId});      }      else if (isEscape(event)) {        keyPort.postMessage({keyChar:"<ESC>", frameId:frameId}); @@ -443,40 +432,55 @@ function onKeydown(event) {    // Subject to internationalization issues since we're using keyIdentifier instead of charCode (in keypress).    //    // TOOD(ilya): Revisit this. Not sure it's the absolute best approach. -  if (keyChar == "" && !insertMode && currentCompletionKeys.indexOf(getKeyChar(event)) != -1) +  if (keyChar == "" && !isInsertMode() && (currentCompletionKeys.indexOf(getKeyChar(event)) != -1 || +      validFirstKeys[getKeyChar(event)]))      event.stopPropagation();  } +function onKeyup() { +  if (!bubbleEvent('keyup', event)) +    return; +} +  function checkIfEnabledForUrl() { -    var url = window.location.toString(); - -    chrome.extension.sendRequest({ handler: "isEnabledForUrl", url: url }, function (response) { -      isEnabledForUrl = response.isEnabledForUrl; -      if (isEnabledForUrl) -        initializeWhenEnabled(); -      else if (HUD.isReady()) -        // Quickly hide any HUD we might already be showing, e.g. if we entered insertMode on page load. -        HUD.hide(); -    }); +  var url = window.location.toString(); + +  chrome.extension.sendRequest({ handler: "isEnabledForUrl", url: url }, function (response) { +    isEnabledForUrl = response.isEnabledForUrl; +    if (isEnabledForUrl) +      initializeWhenEnabled(); +    else if (HUD.isReady()) +      // Quickly hide any HUD we might already be showing, e.g. if we entered insert mode on page load. +      HUD.hide(); +  });  } -function refreshCompletionKeys(completionKeys) { -  if (completionKeys) -    currentCompletionKeys = completionKeys; -  else -    chrome.extension.sendRequest({handler: "getCompletionKeys"}, function (response) { -      currentCompletionKeys = response.completionKeys; -    }); +// TODO(ilya): This just checks if "google" is in the domain name. Probably should be more targeted. +function isGoogleSearch() { +  var url = window.location.toString(); +  return !!url.match(googleRegex); +} + +function refreshCompletionKeys(response) { +  if (response) { +    currentCompletionKeys = response.completionKeys; + +    if (response.validFirstKeys) +      validFirstKeys = response.validFirstKeys; +  } +  else { +    chrome.extension.sendRequest({ handler: "getCompletionKeys" }, refreshCompletionKeys); +  }  }  function onFocusCapturePhase(event) {    if (isFocusable(event.target)) -    enterInsertMode(); +    enterInsertModeWithoutShowingIndicator(event.target);  }  function onBlurCapturePhase(event) {    if (isFocusable(event.target)) -    exitInsertMode(); +    exitInsertMode(event.target);  }  /* @@ -488,32 +492,52 @@ 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,   * and will enter enter mode if focused. Also note that the "contentEditable" attribute can be set on   * any element which makes it a rich text editor, like the notes on jjot.com. - * Note: we used to discriminate for text-only inputs, but this is not accurate since all input fields - * can be controlled via the keyboard, particuarlly SELECT combo boxes.   */  function isEditable(target) { -  if (target.getAttribute("contentEditable") == "true") +  if (target.isContentEditable) +    return true; +  var nodeName = target.nodeName.toLowerCase(); +  // use a blacklist instead of a whitelist because new form controls are still being implemented for html5 +  var noFocus = ["radio", "checkbox"]; +  if (nodeName == "input" && noFocus.indexOf(target.type) == -1)      return true; -  var focusableInputs = ["input", "textarea", "select", "button"]; -  return focusableInputs.indexOf(target.tagName.toLowerCase()) >= 0; +  var focusableElements = ["textarea", "select"]; +  return focusableElements.indexOf(nodeName) >= 0;  } -function enterInsertMode() { -  insertMode = true; +/* + * Enters insert mode and show an "Insert mode" message. Showing the UI is only useful when entering insert + * mode manually by pressing "i". In most cases we do not show any UI (enterInsertModeWithoutShowingIndicator) + */ +function enterInsertMode(target) { +  enterInsertModeWithoutShowingIndicator(target);    HUD.show("Insert mode");  } -function exitInsertMode() { -  insertMode = false; -  HUD.hide(); +/* + * We cannot count on 'focus' and 'blur' events to happen sequentially. For example, if blurring element A + * causes element B to come into focus, we may get "B focus" before "A blur". Thus we only leave insert mode + * when the last editable element that came into focus -- which insertModeLock points to -- has been blurred. + * If insert mode is entered manually (via pressing 'i'), then we set insertModeLock to 'undefined', and only + * leave insert mode when the user presses <ESC>. + */ +function enterInsertModeWithoutShowingIndicator(target) { insertModeLock = target; } + +function exitInsertMode(target) { +  if (target === undefined || insertModeLock === target) { +    insertModeLock = null; +    HUD.hide(); +  }  } +function isInsertMode() { return insertModeLock !== null; } +  function handleKeyCharForFindMode(keyChar) {    findModeQuery = findModeQuery + keyChar;    performFindInPlace(); @@ -521,13 +545,11 @@ function handleKeyCharForFindMode(keyChar) {  }  function handleDeleteForFindMode() { -  if (findModeQuery.length == 0) -  { +  if (findModeQuery.length == 0) {      exitFindMode();      performFindInPlace();    } -  else -  { +  else {      findModeQuery = findModeQuery.substring(0, findModeQuery.length - 1);      performFindInPlace();      showFindModeHUDForQuery(); @@ -551,31 +573,51 @@ function performFindInPlace() {    // backwards.    window.scrollTo(cachedScrollX, cachedScrollY); -  performFind(); +  executeFind();  } -function performFind() { -  findModeQueryHasResults = window.find(findModeQuery, false, false, true, false, true, false); +function executeFind(backwards) { +  findModeQueryHasResults = window.find(findModeQuery, false, backwards, true, false, true, false);  } -function performBackwardsFind() { -  findModeQueryHasResults = window.find(findModeQuery, false, true, true, false, true, false); +function focusFoundLink() { +  if (findModeQueryHasResults) { +    var link = getLinkFromSelection(); +    if (link) +      link.focus(); +  } +} + +function findAndFocus(backwards) { +  executeFind(backwards); +  focusFoundLink(); +} + +function performFind() { findAndFocus(); } + +function performBackwardsFind() { findAndFocus(true); } + +function getLinkFromSelection() { +  var node = window.getSelection().anchorNode; +  while (node.nodeName.toLowerCase() !== 'body') { +    if (node.nodeName.toLowerCase() === 'a') return node; +    node = node.parentNode; +  } +  return null;  }  function findAndFollowLink(linkStrings) {    for (i = 0; i < linkStrings.length; i++) { -    var findModeQueryHasResults = window.find(linkStrings[i], false, true, true, false, true, false); -    if (findModeQueryHasResults) { -      var node = window.getSelection().anchorNode; -      while (node.nodeName != 'BODY') { -        if (node.nodeName == 'A') { -          window.location = node.href; -          return true; -        } -        node = node.parentNode; +    var hasResults = window.find(linkStrings[i], false, true, true, false, true, false); +    if (hasResults) { +      var link = getLinkFromSelection(); +      if (link) { +        window.location = link.href; +        return true;        }      }    } +  return false;  }  function findAndFollowRel(value) { @@ -592,13 +634,13 @@ function findAndFollowRel(value) {  }  function goPrevious() { -  var previousPatterns = settings["previousPatterns"] || ""; +  var previousPatterns = settings.get("previousPatterns") || "";    var previousStrings = previousPatterns.split(",");    findAndFollowRel('prev') || findAndFollowLink(previousStrings);  }  function goNext() { -  var nextPatterns = settings["nextPatterns"] || ""; +  var nextPatterns = settings.get("nextPatterns") || "";    var nextStrings = nextPatterns.split(",");    findAndFollowRel('next') || findAndFollowLink(nextStrings);  } @@ -616,12 +658,11 @@ function showFindModeHUDForQuery() {  function insertSpaces(query) {    var newQuery = ""; -  for (var i = 0; i < query.length; i++) -  { +  for (var i = 0; i < query.length; i++) {      if (query[i] == " " || (i + 1 < query.length && query[i + 1] == " "))        newQuery = newQuery + query[i]; -    else -      newQuery = newQuery + query[i] + "<span style=\"font-size: 0px;\"> </span>"; +    else //  ​ is a zero-width space +      newQuery = newQuery + query[i] + "<span>​</span>";    }    return newQuery; @@ -635,6 +676,7 @@ function enterFindMode() {  function exitFindMode() {    findMode = false; +  focusFoundLink();    HUD.hide();  } @@ -648,13 +690,14 @@ function showHelpDialog(html, fid) {    document.body.appendChild(container);    container.innerHTML = html; +  container.getElementsByClassName("closeButton")[0].addEventListener("click", hideHelpDialog, false); +  container.getElementsByClassName("optionsPage")[0].addEventListener("click", +      function() { chrome.extension.sendRequest({ handler: "openOptionsPageInNewTab" }); }, false); +    // This is necessary because innerHTML does not evaluate javascript embedded in <script> tags.    var scripts = Array.prototype.slice.call(container.getElementsByTagName("script"));    scripts.forEach(function(script) { eval(script.text); }); -  container.getElementsByClassName("closeButton")[0].addEventListener("click", hideHelpDialog, false); -  container.getElementsByClassName("optionsPage")[0].addEventListener("click", -      function() { chrome.extension.sendRequest({ handler: "openOptionsPageInNewTab" }); }, false);  }  function hideHelpDialog(clickEvent) { @@ -763,15 +806,6 @@ HUD = {        function() { HUD.upgradeNotificationElement().style.display = "none"; });    }, -  updatePageZoomLevel: function(pageZoomLevel) { -    // Since the chrome HUD does not scale with the page's zoom level, neither will this HUD. -    var inverseZoomLevel = (100.0 / pageZoomLevel) * 100; -    if (HUD._displayElement) -      HUD.displayElement().style.zoom = inverseZoomLevel + "%"; -    if (HUD._upgradeNotificationElement) -      HUD.upgradeNotificationElement().style.zoom = inverseZoomLevel + "%"; -  }, -    /*     * Retrieves the HUD HTML element.     */ @@ -780,7 +814,6 @@ HUD = {        HUD._displayElement = HUD.createHudElement();        // Keep this far enough to the right so that it doesn't collide with the "popups blocked" chrome HUD.        HUD._displayElement.style.right = "150px"; -      HUD.updatePageZoomLevel(currentZoomLevel);      }      return HUD._displayElement;    }, @@ -790,7 +823,6 @@ HUD = {        HUD._upgradeNotificationElement = HUD.createHudElement();        // Position this just to the left of our normal HUD.        HUD._upgradeNotificationElement.style.right = "315px"; -      HUD.updatePageZoomLevel(currentZoomLevel);      }      return HUD._upgradeNotificationElement;    }, | 
