diff options
| -rw-r--r-- | CREDITS | 1 | ||||
| -rw-r--r-- | background/settings.js | 76 | ||||
| -rw-r--r-- | background_page.html | 95 | ||||
| -rw-r--r-- | bookmarks.js | 6 | ||||
| -rw-r--r-- | lib/domUtils.js | 94 | ||||
| -rw-r--r-- | lib/utils.js | 74 | ||||
| -rw-r--r-- | linkHints.js | 91 | ||||
| -rw-r--r-- | manifest.json | 6 | ||||
| -rw-r--r-- | options.html | 37 | ||||
| -rw-r--r-- | test_harnesses/automated.html | 7 | ||||
| -rw-r--r-- | vimium.css | 7 | ||||
| -rw-r--r-- | vimiumFrontend.js | 397 | 
12 files changed, 620 insertions, 271 deletions
| @@ -30,5 +30,6 @@ Contributors:    Wang Ning <daning106@gmail.com> (github:daning)    Bernardo B. Marques <bernardo.fire@gmail.com> (github: bernardofire)    Niklas Baumstark <niklas.baumstark@gmail.com> (github: niklasb) +  Ângelo Otávio Nuffer Nunes <angelonuffer@gmail.com> (github: angelonuffer)  Feel free to add real names in addition to GitHub usernames. diff --git a/background/settings.js b/background/settings.js new file mode 100644 index 00000000..a57b546d --- /dev/null +++ b/background/settings.js @@ -0,0 +1,76 @@ +/* + * Used by everyone to manipulate localStorage. + */ +var settings = { + +  defaults: { +    scrollStepSize: 60, +    linkHintCharacters: "sadfjklewcmpgh", +    filterLinkHints: false, +    userDefinedLinkHintCss: +      "div > .vimiumHintMarker {" + "\n" + +      "/* linkhint boxes */ " + "\n" + +      "background-color: yellow;" + "\n" + +      "border: 1px solid #E3BE23;" + "\n" + +      "}" + "\n\n" + +      "div > .vimiumHintMarker span {" + "\n" + +      "/* linkhint text */ " + "\n" + +      "color: black;" + "\n" + +      "font-weight: bold;" + "\n" + +      "font-size: 12px;" + "\n" + +      "}" + "\n\n" + +      "div > .vimiumHintMarker > .matchingCharacter {" + "\n" + +      "}", +    excludedUrls: "http*://mail.google.com/*\n" + +                  "http*://www.google.com/reader/*\n", + +    // 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,>>", +  }, + +  init: function() { +    // settingsVersion was introduced in v1.31, and is used to coordinate data migration. We do not use +    // previousVersion as it is used to coordinate the display of the upgrade message, and is not updated +    // early enough when the extension loads. +    // 1.31 was also the version where we converted all localStorage values to JSON. +    if (!this.has("settingsVersion")) { +      for (var key in localStorage) { +        localStorage[key] = JSON.stringify(localStorage[key]); +      } +      this.set("settingsVersion", utils.getCurrentVersion()); +    } +  }, + +  get: function(key) { +    if (!(key in localStorage)) +      return this.defaults[key]; +    else +      return JSON.parse(localStorage[key]); +  }, + +  set: function(key, value) { +    // don't store the value if it is equal to the default, so we can change the defaults in the future +    if (value === this.defaults[key]) +      this.clear(key); +    else +      localStorage[key] = JSON.stringify(value); +  }, + +  clear: function(key) { +    delete localStorage[key]; +  }, + +  has: function(key) { +    return key in localStorage; +  }, + +}; + +settings.init(); diff --git a/background_page.html b/background_page.html index 71208d12..706aeb1f 100644 --- a/background_page.html +++ b/background_page.html @@ -3,13 +3,9 @@  <script type="text/javascript" src="commands.js"></script>  <script type="text/javascript" src="lib/clipboard.js"></script>  <script type="text/javascript" src="lib/utils.js"></script> +<script type="text/javascript" src="background/settings.js"></script>  <script type="text/javascript" charset="utf-8"> -  // Chromium #15242 will make this XHR request to access the manifest unnecessary. -  var manifestRequest = new XMLHttpRequest(); -  manifestRequest.open("GET", chrome.extension.getURL("manifest.json"), false); -  manifestRequest.send(null); - -  var currentVersion = JSON.parse(manifestRequest.responseText).version; +  var currentVersion = utils.getCurrentVersion();    var tabQueue = {}; // windowId -> Array    var openTabs = {}; // tabId -> object with various tab properties @@ -25,36 +21,12 @@    // the string.    var namedKeyRegex = /^(<(?:[amc]-.|(?:[amc]-)?[a-z0-9]{2,5})>)(.*)$/; -  var defaultSettings = { -    scrollStepSize: 60, -    linkHintCharacters: "sadfjklewcmpgh", -    filterLinkHints: false, -    userDefinedLinkHintCss: -      "#vimiumHintMarkerContainer .vimiumHintMarker \n/* linkhint boxes */ " + -      "{\nborder: 1px solid #AA852F;\n}\n\n" + -      "#vimiumHintMarkerContainer .vimiumHintMarker span \n/* linkhint text */ " + -      "{\nfont-weight: bold;\n}\n\n" + -      "#vimiumHintMarkerContainer .vimiumHintMarker > .matchingCharacter {\n\n}", -    excludedUrls: "http*://mail.google.com/*\n" + -                  "http*://www.google.com/reader/*\n", - -    // 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,>>", -  }; -    // Port handler mapping    var portHandlers = {      keyDown:              handleKeyDown,      returnScrollPosition: handleReturnScrollPosition,      getCurrentTabUrl:     getCurrentTabUrl, -    getSetting:           getSetting, +    settings:             handleSettings,      getBookmarks:         getBookmarks    }; @@ -66,6 +38,7 @@      openOptionsPageInNewTab: openOptionsPageInNewTab,      registerFrame: registerFrame,      frameFocused: handleFrameFocused, +    focusTopFrame: focusTopFrame,      upgradeNotificationClosed: upgradeNotificationClosed,      updateScrollPosition: handleUpdateScrollPosition,      copyToClipboard: copyToClipboard, @@ -132,7 +105,7 @@     */    function isEnabledForUrl(request) {      // excludedUrls are stored as a series of URL expressions separated by newlines. -    var excludedUrls = getSettingFromLocalStorage("excludedUrls").split("\n"); +    var excludedUrls = settings.get("excludedUrls").split("\n");      var isEnabled = true;      for (var i = 0; i < excludedUrls.length; i++) {        // The user can add "*" to the URL which means ".*" @@ -144,7 +117,7 @@    }    function saveHelpDialogSettings(request) { -    localStorage["helpDialog_showAdvancedCommands"] = request.showAdvancedCommands; +    settings.set("helpDialog_showAdvancedCommands", request.showAdvancedCommands);    }    function showHelp(callback, frameId) { @@ -170,8 +143,7 @@                                          showUnboundCommands, showCommandNames));      dialogHtml = dialogHtml.replace("{{version}}", currentVersion);      dialogHtml = dialogHtml.replace("{{title}}", customTitle || "Help"); -    dialogHtml = dialogHtml.replace("{{showAdvancedCommands}}", -        localStorage["helpDialog_showAdvancedCommands"] == "true"); +    dialogHtml = dialogHtml.replace("{{showAdvancedCommands}}", settings.get("helpDialog_showAdvancedCommands"));      return dialogHtml;    } @@ -259,7 +231,7 @@     * Returns the user-provided CSS overrides.     */    function getLinkHintCss(request) { -    return { linkHintCss: (localStorage['userDefinedLinkHintCss'] || "") }; +    return { linkHintCss: (settings.get("userDefinedLinkHintCss") || "") };    }    /* @@ -267,7 +239,7 @@     * We should now dismiss that message in all tabs.     */    function upgradeNotificationClosed(request) { -    localStorage.previousVersion = currentVersion; +    settings.set("previousVersion", currentVersion);      sendRequestToAllTabs({ name: "hideUpgradeNotification" });    } @@ -281,10 +253,14 @@    /*     * Used by the content scripts to get settings from the local storage.     */ -  function getSetting(args, port) { -    var value = getSettingFromLocalStorage(args.key); -    var returnPort = chrome.tabs.connect(port.tab.id, { name: "returnSetting" }); -    returnPort.postMessage({ key: args.key, value: value }); +  function handleSettings(args, port) { +    if (args.operation == "get") { +      var value = settings.get(args.key); +      port.postMessage({ key: args.key, value: value }); +    } +    else { // operation == "set" +      settings.set(args.key, args.value); +    }    }    function getBookmarks(args, port) { @@ -293,17 +269,6 @@      })    } -  /* -   * Used by everyone to get settings from local storage. -   */ -  function getSettingFromLocalStorage(setting) { -    if (localStorage[setting] != "" && !localStorage[setting]) { -      return defaultSettings[setting]; -    } else { -      return localStorage[setting]; -    } -  } -    function getCurrentTimeInSeconds() { Math.floor((new Date()).getTime() / 1000); }    chrome.tabs.onSelectionChanged.addListener(function(tabId, selectionInfo) { @@ -644,11 +609,11 @@     * localStorage, and false otherwise.     */    function shouldShowUpgradeMessage() { -    // Avoid showing the upgrade notification when localStorage.previousVersion is undefined, which is the -    // case for new installs. -    if (!localStorage.previousVersion) -      localStorage.previousVersion = currentVersion; -    return compareVersions(currentVersion, localStorage.previousVersion) == 1; +    // Avoid showing the upgrade notification when previousVersion is undefined, which is the case for new +    // installs. +    if (!settings.get("previousVersion")) +      settings.set("previousVersion", currentVersion); +    return compareVersions(currentVersion, settings.get("previousVersion")) == 1;    }    function openOptionsPageInNewTab() { @@ -663,6 +628,7 @@      if (request.is_top) {        focusedFrame = request.frameId; +      framesForTab[sender.tab.id].topId = request.frameId;        framesForTab[sender.tab.id].total = request.total;      } @@ -690,6 +656,11 @@      chrome.tabs.sendRequest(tabId, { name: "focusFrame", frameId: mainFrameId, highlight: false });    } +  function focusTopFrame(request, sender) { +    var tabId = sender.tab.id; +    chrome.tabs.sendRequest(tabId, { name: "focusFrame", frameId: framesForTab[tabId].topId, highlight: true }); +  } +    function handleFrameFocused(request, sender) {      focusedFrame = request.frameId;    } @@ -720,20 +691,20 @@    function init() {      clearKeyMappingsAndSetDefaults(); -    if (localStorage["keyMappings"]) -      parseCustomKeyMappings(localStorage["keyMappings"]); +    if (settings.has("keyMappings")) +      parseCustomKeyMappings(settings.get("keyMappings"));      // In version 1.22, we changed the mapping for "d" and "u" to be scroll page down/up instead of close      // and restore tab. For existing users, we want to preserve existing behavior for them by adding some      // custom key mappings on their behalf. -    if (localStorage.previousVersion == "1.21") { -      var customKeyMappings = localStorage["keyMappings"] || ""; +    if (settings.get("previousVersion") == "1.21") { +      var customKeyMappings = settings.get("keyMappings") || "";        if ((keyToCommandRegistry["d"] || {}).command == "scrollPageDown")          customKeyMappings += "\nmap d removeTab";        if ((keyToCommandRegistry["u"] || {}).command == "scrollPageUp")          customKeyMappings += "\nmap u restoreTab";        if (customKeyMappings != "") { -        localStorage["keyMappings"] = customKeyMappings; +        settings.set("keyMappings", customKeyMappings);          parseCustomKeyMappings(customKeyMappings);        }      } diff --git a/bookmarks.js b/bookmarks.js index 67ef1cb3..85d12aac 100644 --- a/bookmarks.js +++ b/bookmarks.js @@ -69,10 +69,12 @@ function activateBookmarkFindMode() {          var url = selection.url;          var isABookmarklet = function(url) { return url.indexOf("javascript:") === 0; } -        if (!self.newTab || isABookmarklet(url)) +        if (isABookmarklet(url))            window.location = url; +        else if (!self.newTab) +          chrome.extension.sendRequest({ handler: "openUrlInCurrentTab", url: url });          else -          window.open(url); +          chrome.extension.sendRequest({ handler: "openUrlInNewTab", url: url });          self.disable();        }, diff --git a/lib/domUtils.js b/lib/domUtils.js new file mode 100644 index 00000000..fd182c59 --- /dev/null +++ b/lib/domUtils.js @@ -0,0 +1,94 @@ +var domUtils = { +  /* +   * 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); +  }, + +  /** +   * 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(); +    var clientRectsLength = clientRects.length; + +    for (var i = 0; i < clientRectsLength; i++) { +      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; + +      return clientRects[i]; +    } + +    for (var i = 0; i < clientRectsLength; i++) { +      // 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; +        } +      } +    }; +    return null; +  }, + +  /* +   * Selectable means the element has a text caret; this is not the same as "focusable". +   */ +  isSelectable: function(element) { +    var selectableTypes = ["search", "text", "password"]; +    return (element.nodeName.toLowerCase() == "input" && selectableTypes.indexOf(element.type) >= 0) || +        element.nodeName.toLowerCase() == "textarea"; +  }, + +  simulateSelect: function(element) { +    element.focus(); +    // When focusing a textbox, put the selection caret at the end of the textbox's contents. +    element.setSelectionRange(element.value.length, element.value.length); +  }, + +  simulateClick: function(element, modifiers) { +    modifiers = modifiers || {}; + +    var eventSequence = [ "mouseover", "mousedown", "mouseup", "click" ]; +    for (var i = 0; i < eventSequence.length; i++) { +      var event = document.createEvent("MouseEvents"); +      event.initMouseEvent(eventSequence[i], true, true, window, 1, 0, 0, 0, 0, modifiers.ctrlKey, false, false, +                           modifiers.metaKey, 0, null); +      // Debugging note: Firefox will not execute the element'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 +      element.dispatchEvent(event); +    } +  }, + +}; diff --git a/lib/utils.js b/lib/utils.js index efdb49fb..8aada3a1 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,4 +1,12 @@  var utils = { +  getCurrentVersion: function() { +    // Chromium #15242 will make this XHR request to access the manifest unnecessary. +    var manifestRequest = new XMLHttpRequest(); +    manifestRequest.open("GET", chrome.extension.getURL("manifest.json"), false); +    manifestRequest.send(null); +    return JSON.parse(manifestRequest.responseText).version; +  }, +    /*     * Takes a dot-notation object string and call the function     * that it points to with the correct value for 'this'. @@ -12,69 +20,6 @@ var utils = {      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); -  }, - -  /** -   * 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(); -    var clientRectsLength = clientRects.length; - -    for (var i = 0; i < clientRectsLength; i++) { -      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; - -      return clientRects[i]; -    } - -    for (var i = 0; i < clientRectsLength; i++) { -      // 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; -        } -      } -    }; -    return null; -  }, -    /**     * Creates a search URL from the given :query.     */ @@ -105,6 +50,9 @@ var utils = {      // trim str      str = str.replace(/^\s+|\s+$/g, ''); +    if (str[0] === '/') +      return "file://" + str; +      // it starts with a scheme, so it's definitely an URL      if (/^[a-z]{3,}:\/\//.test(str))        return str; diff --git a/linkHints.js b/linkHints.js index 24fa7946..509b6c0d 100644 --- a/linkHints.js +++ b/linkHints.js @@ -41,7 +41,7 @@ var linkHints = {     * The final expression will be something like "//button | //xhtml:button | ..."     * We use translate() instead of lower-case() because Chrome only supports XPath 1.0.     */ -  clickableElementsXPath: utils.makeXPath(["a", "area[@href]", "textarea", "button", "select","input[not(@type='hidden')]", +  clickableElementsXPath: domUtils.makeXPath(["a", "area[@href]", "textarea", "button", "select","input[not(@type='hidden')]",                               "*[@onclick or @tabindex or @role='link' or @role='button' or " +                               "@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"]), @@ -97,7 +97,7 @@ var linkHints = {      // Also note that adding these nodes to document.body all at once is significantly faster than one-by-one.      this.hintMarkerContainingDiv = document.createElement("div");      this.hintMarkerContainingDiv.id = "vimiumHintMarkerContainer"; -    this.hintMarkerContainingDiv.className = "vimiumReset internalVimiumHintMarker"; +    this.hintMarkerContainingDiv.className = "vimiumReset";      for (var i = 0; i < this.hintMarkers.length; i++)        this.hintMarkerContainingDiv.appendChild(this.hintMarkers[i]); @@ -115,14 +115,14 @@ var linkHints = {     * 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 resultSet = domUtils.evaluateXPath(this.clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);      var visibleElements = [];      // Find all visible clickable elements.      for (var i = 0, count = resultSet.snapshotLength; i < count; i++) {        var element = resultSet.snapshotItem(i); -      var clientRect = utils.getVisibleClientRect(element, clientRect); +      var clientRect = domUtils.getVisibleClientRect(element, clientRect);        if (clientRect !== null)          visibleElements.push({element: element, rect: clientRect}); @@ -132,7 +132,7 @@ var linkHints = {          var img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']");          if (!img) continue;          var imgClientRects = img.getClientRects(); -        if (!imgClientRects) continue; +        if (imgClientRects.length == 0) continue;          var c = element.coords.split(/,/);          var coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)];          var rect = { @@ -204,8 +204,8 @@ var linkHints = {    activateLink: function(matchedLink, delay) {      var that = this;      this.delayMode = true; -    if (this.isSelectable(matchedLink)) { -      this.simulateSelect(matchedLink); +    if (domUtils.isSelectable(matchedLink)) { +      domUtils.simulateSelect(matchedLink);        this.deactivateMode(delay, function() { that.delayMode = false; });      } else {        if (this.shouldOpenWithQueue) { @@ -231,32 +231,18 @@ var linkHints = {      }    }, -  /* -   * 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"; +      (j < matchingCharCount) ? linkMarker.childNodes[j].classList.add("matchingCharacter") : +                                linkMarker.childNodes[j].classList.remove("matchingCharacter");    },    hideMarker: function(linkMarker) { @@ -264,16 +250,11 @@ var linkHints = {    },    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); +    domUtils.simulateClick(link, { metaKey: metaKey, ctrlKey: ctrlKey });      // TODO(int3): do this for @role='link' and similar elements as well      var nodeName = link.nodeName.toLowerCase(); @@ -317,10 +298,9 @@ var alphabetHints = {      var hintStrings = this.hintStrings(visibleElements.length);      var hintMarkers = [];      for (var i = 0, count = visibleElements.length; i < count; i++) { -      var hintString = hintStrings[i];        var marker = hintUtils.createMarkerFor(visibleElements[i]); -      marker.innerHTML = hintUtils.spanWrap(hintString); -      marker.setAttribute("hintString", hintString); +      marker.hintString = hintStrings[i]; +      marker.innerHTML = hintUtils.spanWrap(marker.hintString.toUpperCase());        hintMarkers.push(marker);      } @@ -407,7 +387,7 @@ var alphabetHints = {      var matchString = this.hintKeystrokeQueue.join("");      var linksMatched = hintMarkers.filter(function(linkMarker) { -      return linkMarker.getAttribute("hintString").indexOf(matchString) == 0; +      return linkMarker.hintString.indexOf(matchString) == 0;      });      return { linksMatched: linksMatched };    }, @@ -440,11 +420,13 @@ var filterHints = {      }    }, -  setMarkerAttributes: function(marker, linkHintNumber) { -    var hintString = (linkHintNumber + 1).toString(); +  generateHintString: function(linkHintNumber) { +    return (linkHintNumber + 1).toString(); +  }, + +  generateLinkText: function(element) {      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(); @@ -466,10 +448,12 @@ var filterHints = {      } else {        linkText = element.textContent || element.innerHTML;      } -    linkText = linkText.trim().toLowerCase(); -    marker.setAttribute("hintString", hintString); -    marker.innerHTML = hintUtils.spanWrap(hintString + (showLinkText ? ": " + linkText : "")); -    marker.setAttribute("linkText", linkText); +    return { text: linkText, show: showLinkText }; +  }, + +  renderMarker: function(marker) { +    marker.innerHTML = hintUtils.spanWrap(marker.hintString + +                                          (marker.showLinkText ? ": " + marker.linkText : ""));    },    getHintMarkers: function(visibleElements) { @@ -477,7 +461,11 @@ var filterHints = {      var hintMarkers = [];      for (var i = 0, count = visibleElements.length; i < count; i++) {        var marker = hintUtils.createMarkerFor(visibleElements[i]); -      this.setMarkerAttributes(marker, i); +      marker.hintString = this.generateHintString(i); +      var linkTextObject = this.generateLinkText(marker.clickableItem); +      marker.linkText = linkTextObject.text; +      marker.showLinkText = linkTextObject.show; +      this.renderMarker(marker);        hintMarkers.push(marker);      }      return hintMarkers; @@ -516,8 +504,7 @@ var filterHints = {      var linksMatched = this.filterLinkHints(hintMarkers);      var matchString = this.hintKeystrokeQueue.join("");      linksMatched = linksMatched.filter(function(linkMarker) { -      return linkMarker.getAttribute('filtered') != 'true' -        && linkMarker.getAttribute("hintString").indexOf(matchString) == 0; +      return !linkMarker.filtered && linkMarker.hintString.indexOf(matchString) == 0;      });      if (linksMatched.length == 1 && userIsTypingLinkText) { @@ -531,8 +518,8 @@ var filterHints = {    },    /* -   * Hides the links that do not match the linkText search string and marks them with the 'filtered' DOM -   * property. Renumbers the remainder. +   * Marks the links that do not match the linkText search string with the 'filtered' DOM property. Renumbers +   * the remainder if necessary.     */    filterLinkHints: function(hintMarkers) {      var linksMatched = []; @@ -540,14 +527,16 @@ var filterHints = {      for (var i = 0; i < hintMarkers.length; i++) {        var linkMarker = hintMarkers[i]; -      var matchedLink = linkMarker.getAttribute("linkText").toLowerCase() -                                  .indexOf(linkSearchString.toLowerCase()) >= 0; +      var matchedLink = linkMarker.linkText.toLowerCase().indexOf(linkSearchString.toLowerCase()) >= 0;        if (!matchedLink) { -        linkMarker.setAttribute("filtered", "true"); +        linkMarker.filtered = true;        } else { -        this.setMarkerAttributes(linkMarker, linksMatched.length); -        linkMarker.setAttribute("filtered", "false"); +        linkMarker.filtered = false; +        var oldHintString = linkMarker.hintString; +        linkMarker.hintString = this.generateHintString(linksMatched.length); +        if (linkMarker.hintString != oldHintString) +          this.renderMarker(linkMarker);          linksMatched.push(linkMarker);        }      } @@ -569,7 +558,7 @@ var hintUtils = {    spanWrap: function(hintString) {      var innerHTML = [];      for (var i = 0; i < hintString.length; i++) -      innerHTML.push("<span class='vimiumReset'>" + hintString[i].toUpperCase() + "</span>"); +      innerHTML.push("<span class='vimiumReset'>" + hintString[i] + "</span>");      return innerHTML.join("");    }, diff --git a/manifest.json b/manifest.json index 4b14f444..2f863bc3 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@  {    "name": "Vimium", -  "version": "1.30", +  "version": "1.31",    "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", @@ -11,14 +11,14 @@      "tabs",      "bookmarks",      "clipboardRead", -    "http://*/*", -    "https://*/*" +    "<all_urls>"    ],    "content_scripts": [      {        "matches": ["<all_urls>"],        "js": ["lib/utils.js",               "lib/keyboardUtils.js", +             "lib/domUtils.js",               "lib/clipboard.js",               "linkHints.js",               "vimiumFrontend.js", diff --git a/options.html b/options.html index 3d4033e3..037aabbc 100644 --- a/options.html +++ b/options.html @@ -3,9 +3,12 @@      <title>Vimium Options</title>      <script src="lib/utils.js"></script>      <script src="lib/keyboardUtils.js"></script> -    <script src="linkHints.js"></script> +    <script src="lib/domUtils.js"></script>      <script src="lib/clipboard.js"></script> +    <script src="linkHints.js"></script>      <script src="vimiumFrontend.js"></script> +    <script src="completionDialog.js"></script> +    <script src="bookmarks.js"></script>      <style type="text/css" media="screen">        body {          font-family:"helvetica neue", "helvetica", "arial", "sans"; @@ -83,7 +86,7 @@    <script type="text/javascript">    $ = function(id) { return document.getElementById(id); }; -  var defaultSettings = chrome.extension.getBackgroundPage().defaultSettings; +  var defaultSettings = chrome.extension.getBackgroundPage().settings.defaults;    var editableFields = ["scrollStepSize", "excludedUrls", "linkHintCharacters", "userDefinedLinkHintCss",                          "keyMappings", "filterLinkHints", "previousPatterns", "nextPatterns"]; @@ -100,7 +103,10 @@    };    function initializeOptions() { -    populateOptions(); +    if (settings.isLoaded) +      populateOptions(); +    else +      settings.addEventListener("load", populateOptions);      for (var i = 0; i < editableFields.length; i++) {        $(editableFields[i]).addEventListener("keyup", onOptionKeyup, false); @@ -142,18 +148,12 @@          field.value = fieldValue;        } -      var defaultFieldValue = (defaultSettings[fieldName] != null) ? -        defaultSettings[fieldName].toString() : ""; - -      // Don't save to storage if it's equal to the default -      if (fieldValue == defaultFieldValue) -        delete localStorage[fieldName]; -      // ..or if it's empty and not a field that we allow to be empty. -      else if (!fieldValue && canBeEmptyFields.indexOf(fieldName) == -1) { -        delete localStorage[fieldName]; -        fieldValue = defaultFieldValue; +      // If it's empty and not a field that we allow to be empty, restore to the default value +      if (!fieldValue && canBeEmptyFields.indexOf(fieldName) == -1) { +        settings.clear(fieldName); +        fieldValue = settings.get(fieldName);        } else -        localStorage[fieldName] = fieldValue; +        settings.set(fieldName, fieldValue);        $(fieldName).value = fieldValue;        $(fieldName).setAttribute("savedValue", fieldValue); @@ -166,13 +166,8 @@    // Restores select box state to saved value from localStorage.    function populateOptions() {      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]]) { -        var val = defaultSettings[editableFields[i]] || ""; -      } else { -	    var val = localStorage[editableFields[i]]; -      } -	  setFieldValue($(editableFields[i]), val); +      var val = settings.get(editableFields[i]) || ""; +      setFieldValue($(editableFields[i]), val);      }      onDataLoaded();    } diff --git a/test_harnesses/automated.html b/test_harnesses/automated.html index e57f1513..c8e8070a 100644 --- a/test_harnesses/automated.html +++ b/test_harnesses/automated.html @@ -29,6 +29,7 @@      <link rel="stylesheet" type="text/css" href="../vimium.css" />      <script type="text/javascript" src="../lib/utils.js"></script>      <script type="text/javascript" src="../lib/keyboardUtils.js"></script> +    <script type="text/javascript" src="../lib/domUtils.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> @@ -120,7 +121,7 @@          should("label the hints correctly", function() {            var hintStrings = ["ss", "as", "ds"];            for (var i = 0; i < 3; i++) -          assert.equal(hintStrings[i], linkHints.hintMarkers[i].getAttribute("hintString")); +          assert.equal(hintStrings[i], linkHints.hintMarkers[i].hintString);          }),          should("narrow the hints", function() { @@ -164,10 +165,10 @@              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("1", linkHints.hintMarkers[1].hintString);              assert.equal("", linkHints.hintMarkers[1].style.display);              linkHints.onKeyDownInMode(mockKeyboardEvent("A")); -            assert.equal("2", linkHints.hintMarkers[3].getAttribute("hintString")); +            assert.equal("2", linkHints.hintMarkers[3].hintString);            })          ), @@ -56,6 +56,7 @@ div.internalVimiumHintMarker {    display: block;    top: -1px;    left: -1px; +  white-space: nowrap;    font-size: 10px;    padding: 2px 4px 3px 4px; @@ -278,4 +279,8 @@ div.vimium-completions div strong{  div.vimium-completions div.vimium-noResults{    color:#555; -}; +} + +body.vimiumFindMode ::selection { +  background: #ff9632; +} diff --git a/vimiumFrontend.js b/vimiumFrontend.js index e217e955..1a46a58a 100644 --- a/vimiumFrontend.js +++ b/vimiumFrontend.js @@ -8,12 +8,12 @@ var getCurrentUrlHandlers = []; // function(url)  var insertModeLock = null;  var findMode = false; -var findModeQuery = ""; +var findModeQuery = { rawQuery: "" };  var findModeQueryHasResults = false; +var findModeAnchorNode = null;  var isShowingHelpDialog = false;  var handlerStack = [];  var keyPort; -var settingPort;  // Users can disable Vimium on URL patterns via the settings page.  var isEnabledForUrl = true;  // The user's operating system. @@ -34,36 +34,65 @@ var textInputXPath = (function() {    var inputElements = ["input[" +      textInputTypes.map(function (type) { return '@type="' + type + '"'; }).join(" or ") + "or not(@type)]",      "textarea", "*[@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"]; -  return utils.makeXPath(inputElements); +  return domUtils.makeXPath(inputElements);  })(); +/** + * settings provides a browser-global localStorage-backed dict. get() and set() are synchronous, but load() + * must be called beforehand to ensure get() will return up-to-date values. + */  var settings = { +  port: null,    values: {},    loadedValues: 0, -  valuesToLoad: ["scrollStepSize", "linkHintCharacters", "filterLinkHints", "previousPatterns", "nextPatterns"], +  valuesToLoad: ["scrollStepSize", "linkHintCharacters", "filterLinkHints", "previousPatterns", "nextPatterns", +                 "findModeRawQuery"], +  isLoaded: false, +  eventListeners: {}, + +  init: function () { +    this.port = chrome.extension.connect({ name: "settings" }); +    this.port.onMessage.addListener(this.receiveMessage); +  },    get: function (key) { return this.values[key]; }, -  load: function() { -    for (var i in this.valuesToLoad) { this.sendMessage(this.valuesToLoad[i]); } +  set: function (key, value) { +    if (!this.port) +      this.init(); + +    this.values[key] = value; +    this.port.postMessage({ operation: "set", key: key, value: value });    }, -  sendMessage: function (key) { -    if (!settingPort) -      settingPort = chrome.extension.connect({ name: "getSetting" }); -    settingPort.postMessage({ key: key }); +  load: function() { +    if (!this.port) +      this.init(); + +    for (var i in this.valuesToLoad) { +      this.port.postMessage({ operation: "get", key: this.valuesToLoad[i] }); +    }    },    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(); +    // since load() can be called more than once, loadedValues can be greater than valuesToLoad, but we test +    // for equality so initializeOnReady only runs once +    if (++settings.loadedValues == settings.valuesToLoad.length) { +      settings.isLoaded = true; +      var listener; +      while (listener = settings.eventListeners["load"].pop()) +        listener(); +    } +  }, + +  addEventListener: function(eventName, callback) { +    if (!(eventName in this.eventListeners)) +      this.eventListeners[eventName] = []; +    this.eventListeners[eventName].push(callback);    }, -  initializeOnReady: function () { -    linkHints.init(); -  }  };  /* @@ -78,6 +107,7 @@ var googleRegex = /:\/\/[^/]*google[^/]+/;   * Complete initialization work that sould be done prior to DOMReady.   */  function initializePreDomReady() { +  settings.addEventListener("load", linkHints.init.bind(linkHints));    settings.load();    checkIfEnabledForUrl(); @@ -146,8 +176,6 @@ function initializePreDomReady() {        port.onMessage.addListener(function(args) {          if (getCurrentUrlHandlers.length > 0) { getCurrentUrlHandlers.pop()(args.url); }        }); -    } else if (port.name == "returnSetting") { -      port.onMessage.addListener(settings.receiveMessage);      } else if (port.name == "refreshCompletionKeys") {        port.onMessage.addListener(function (args) {          refreshCompletionKeys(args.completionKeys); @@ -174,6 +202,8 @@ function initializeWhenEnabled() {   * The backend needs to know which frame has focus.   */  window.addEventListener("focus", function(e) { +  // settings may have changed since the frame last had focus +  settings.load();    chrome.extension.sendRequest({ handler: "frameFocused", frameId: frameId });  }); @@ -215,7 +245,7 @@ function registerFrameIfSizeAvailable (is_top) {   * Enters insert mode if the currently focused element in the DOM is focusable.   */  function enterInsertModeIfElementIsFocused() { -  if (document.activeElement && isEditable(document.activeElement)) +  if (document.activeElement && isEditable(document.activeElement) && !findMode)      enterInsertModeWithoutShowingIndicator(document.activeElement);  } @@ -234,7 +264,7 @@ function scrollActivatedElementBy(x, y) {      return;    } -  if (!activatedElement || utils.getVisibleClientRect(activatedElement) === null) +  if (!activatedElement || domUtils.getVisibleClientRect(activatedElement) === null)      activatedElement = document.body;    // Chrome does not report scrollHeight accurately for nodes with pseudo-elements of height 0 (bug 110149). @@ -275,16 +305,16 @@ 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() { scrollActivatedElementBy(0, -1 * settings.get("scrollStepSize")); } -function scrollDown() { scrollActivatedElementBy(0, settings.get("scrollStepSize")); } +function scrollDown() { scrollActivatedElementBy(0, parseFloat(settings.get("scrollStepSize"))); }  function scrollPageUp() { scrollActivatedElementBy(0, -1 * window.innerHeight / 2); }  function scrollPageDown() { scrollActivatedElementBy(0, window.innerHeight / 2); }  function scrollFullPageUp() { scrollActivatedElementBy(0, -window.innerHeight); }  function scrollFullPageDown() { scrollActivatedElementBy(0, window.innerHeight); }  function scrollLeft() { scrollActivatedElementBy(-1 * settings.get("scrollStepSize"), 0); } -function scrollRight() { scrollActivatedElementBy(settings.get("scrollStepSize"), 0); } +function scrollRight() { scrollActivatedElementBy(parseFloat(settings.get("scrollStepSize")), 0); }  function focusInput(count) { -  var results = utils.evaluateXPath(textInputXPath, XPathResult.ORDERED_NODE_ITERATOR_TYPE); +  var results = domUtils.evaluateXPath(textInputXPath, XPathResult.ORDERED_NODE_ITERATOR_TYPE);    var lastInputBox;    var i = 0; @@ -293,7 +323,7 @@ function focusInput(count) {      var currentInputBox = results.iterateNext();      if (!currentInputBox) { break; } -    if (utils.getVisibleClientRect(currentInputBox) === null) +    if (domUtils.getVisibleClientRect(currentInputBox) === null)          continue;      lastInputBox = currentInputBox; @@ -339,7 +369,7 @@ function copyCurrentUrl() {    var getCurrentUrlPort = chrome.extension.connect({ name: "getCurrentTabUrl" });    getCurrentUrlPort.postMessage({}); -	HUD.showForDuration("Yanked URL", 1000); +  HUD.showForDuration("Yanked URL", 1000);  }  function toggleViewSourceCallback(url) { @@ -377,10 +407,7 @@ function onKeypress(event) {      if (keyChar) {        if (findMode) {          handleKeyCharForFindMode(keyChar); - -        // Don't let the space scroll us if we're searching. -        if (event.keyCode == keyCodes.space) -          event.preventDefault(); +        suppressEvent(event);        } else if (!isInsertMode() && !findMode) {          if (currentCompletionKeys.indexOf(keyChar) != -1) {            event.preventDefault(); @@ -410,6 +437,11 @@ function bubbleEvent(type, event) {    return true;  } +function suppressEvent(event) { +  event.preventDefault(); +  event.stopPropagation(); +} +  function onKeydown(event) {    if (!bubbleEvent('keydown', event))      return; @@ -459,15 +491,19 @@ function onKeydown(event) {    }    else if (findMode) {      if (isEscape(event)) { -      exitFindMode(); -    // Don't let backspace take us back in history. +      handleEscapeForFindMode(); +      suppressEvent(event);      }      else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) {        handleDeleteForFindMode(); -      event.preventDefault(); +      suppressEvent(event);      }      else if (event.keyCode == keyCodes.enter) {        handleEnterForFindMode(); +      suppressEvent(event); +    } +    else if (!modifiers) { +      event.stopPropagation();      }    }    else if (isShowingHelpDialog && isEscape(event)) { @@ -484,6 +520,8 @@ function onKeydown(event) {      }      else if (isEscape(event)) {        keyPort.postMessage({keyChar:"<ESC>", frameId:frameId}); +      handleEscapeForNormalMode(); +      suppressEvent(event);      }    } @@ -536,7 +574,7 @@ function refreshCompletionKeys(response) {  }  function onFocusCapturePhase(event) { -  if (isFocusable(event.target)) +  if (isFocusable(event.target) && !findMode)      enterInsertModeWithoutShowingIndicator(event.target);  } @@ -600,46 +638,145 @@ function exitInsertMode(target) {  function isInsertMode() { return insertModeLock !== null; } +// should be called whenever rawQuery is modified. +function updateFindModeQuery() { +  // the query can be treated differently (e.g. as a plain string versus regex depending on the presence of +  // escape sequences. '\' is the escape character and needs to be escaped itself to be used as a normal +  // character. here we grep for the relevant escape sequences. +  findModeQuery.isRegex = false; +  var hasNoIgnoreCaseFlag = false; +  findModeQuery.parsedQuery = findModeQuery.rawQuery.replace(/\\./g, function(match) { +    switch (match) { +      case "\\r": +        findModeQuery.isRegex = true; +        return ''; +      case "\\I": +        hasNoIgnoreCaseFlag = true; +        return ''; +      case "\\\\": +        return "\\"; +      default: +        return match; +    } +  }); + +  // default to 'smartcase' mode, unless noIgnoreCase is explicitly specified +  findModeQuery.ignoreCase = !hasNoIgnoreCaseFlag && !/[A-Z]/.test(findModeQuery.parsedQuery); + +  // if we are dealing with a regex, grep for all matches in the text, and then call window.find() on them +  // sequentially so the browser handles the scrolling / text selection. +  if (findModeQuery.isRegex) { +    try { +      var pattern = new RegExp(findModeQuery.parsedQuery, "g" + (findModeQuery.ignoreCase ? "i" : "")); +    } +    catch (e) { +      // if we catch a SyntaxError, assume the user is not done typing yet and return quietly +      return; +    } +    // innerText will not return the text of hidden elements, and strip out tags while preserving newlines +    var text = document.body.innerText; +    findModeQuery.regexMatches = text.match(pattern); +    findModeQuery.activeRegexIndex = 0; +  } +} +  function handleKeyCharForFindMode(keyChar) { -  findModeQuery = findModeQuery + keyChar; +  findModeQuery.rawQuery += keyChar; +  updateFindModeQuery();    performFindInPlace();    showFindModeHUDForQuery();  } +function handleEscapeForFindMode() { +  exitFindMode(); +  document.body.classList.remove("vimiumFindMode"); +  // removing the class does not re-color existing selections. we recreate the current selection so it reverts +  // back to the default color. +  var selection = window.getSelection(); +  if (!selection.isCollapsed) { +    var range = window.getSelection().getRangeAt(0); +    window.getSelection().removeAllRanges(); +    window.getSelection().addRange(range); +  } +  focusFoundLink() || selectFoundInputElement(); +} +  function handleDeleteForFindMode() { -  if (findModeQuery.length == 0) { +  if (findModeQuery.rawQuery.length == 0) {      exitFindMode();      performFindInPlace();    }    else { -    findModeQuery = findModeQuery.substring(0, findModeQuery.length - 1); +    findModeQuery.rawQuery = findModeQuery.rawQuery.substring(0, findModeQuery.rawQuery.length - 1); +    updateFindModeQuery();      performFindInPlace();      showFindModeHUDForQuery();    }  } +// <esc> sends us into insert mode if possible, but <cr> does not. +// <esc> corresponds approximately to 'nevermind, I have found it already' while <cr> means 'I want to save +// this query and do more searches with it'  function handleEnterForFindMode() {    exitFindMode(); -  performFindInPlace(); +  focusFoundLink(); +  document.body.classList.add("vimiumFindMode"); +  settings.set("findModeRawQuery", findModeQuery.rawQuery);  }  function performFindInPlace() {    var cachedScrollX = window.scrollX;    var cachedScrollY = window.scrollY; +  if (findModeQuery.isRegex) { +    if (!findModeQuery.regexMatches) { +      findModeQueryHasResults = false; +      return; +    } +    else +      var query = findModeQuery.regexMatches[0]; +  } +  else +    var query = findModeQuery.parsedQuery; +    // Search backwards first to "free up" the current word as eligible for the real forward search. This allows    // us to search in place without jumping around between matches as the query grows. -  window.find(findModeQuery, false, true, true, false, true, false); +  executeFind(query, { backwards: true, caseSensitive: !findModeQuery.ignoreCase });    // We need to restore the scroll position because we might've lost the right position by searching    // backwards.    window.scrollTo(cachedScrollX, cachedScrollY); -  executeFind(); +  findModeQueryHasResults = executeFind(query, { caseSensitive: !findModeQuery.ignoreCase }); +} + +// :options is an optional dict. valid parameters are 'caseSensitive' and 'backwards'. +function executeFind(query, options) { +  options = options || {}; + +  // rather hacky, but this is our way of signalling to the insertMode listener not to react to the focus +  // changes that find() induces. +  var oldFindMode = findMode; +  findMode = true; + +  document.body.classList.add("vimiumFindMode"); + +  // ignore the selectionchange event generated by find() +  document.removeEventListener("selectionchange",restoreDefaultSelectionHighlight, true); +  var rv = window.find(query, options.caseSensitive, options.backwards, true, false, true, false); +  setTimeout(function() { +    document.addEventListener("selectionchange", restoreDefaultSelectionHighlight, true); +  }, 0); + +  findMode = oldFindMode; +  // we need to save the anchor node here because <esc> seems to nullify it, regardless of whether we do +  // preventDefault() +  findModeAnchorNode = document.getSelection().anchorNode; +  return rv;  } -function executeFind(backwards) { -  findModeQueryHasResults = window.find(findModeQuery, false, backwards, true, false, true, false); +function restoreDefaultSelectionHighlight() { +  document.body.classList.remove("vimiumFindMode");  }  function focusFoundLink() { @@ -650,8 +787,78 @@ function focusFoundLink() {    }  } +function isDOMDescendant(parent, child) { +  var node = child; +  while (node !== null) { +    if (node === parent) +      return true; +    node = node.parentNode; +  } +  return false; +} + +function selectFoundInputElement() { +  // if the found text is in an input element, getSelection().anchorNode will be null, so we use activeElement +  // instead. however, since the last focused element might not be the one currently pointed to by find (e.g. +  // the current one might be disabled and therefore unable to receive focus), we use the approximate +  // heuristic of checking that the last anchor node is an ancestor of our element. +  if (findModeQueryHasResults && domUtils.isSelectable(document.activeElement) && +      isDOMDescendant(findModeAnchorNode, document.activeElement)) { +    domUtils.simulateSelect(document.activeElement); +    // the element has already received focus via find(), so invoke insert mode manually +    enterInsertModeWithoutShowingIndicator(document.activeElement); +  } +} +  function findAndFocus(backwards) { -  executeFind(backwards); +  // check if the query has been changed by a script in another frame +  var mostRecentQuery = settings.get("findModeRawQuery") || ""; +  if (mostRecentQuery !== findModeQuery.rawQuery) { +    findModeQuery.rawQuery = mostRecentQuery; +    updateFindModeQuery(); +    performFindInPlace(); +    return; +  } + +  if (!findModeQueryHasResults) { +    HUD.showForDuration("No matches for '" + findModeQuery.rawQuery + "'", 1000); +    return; +  } + +  if (findModeQuery.isRegex) { +    if (!backwards) { +      if (++findModeQuery.activeRegexIndex == findModeQuery.regexMatches.length) +        findModeQuery.activeRegexIndex = 0; +    } +    else { +      if (--findModeQuery.activeRegexIndex == -1) +        findModeQuery.activeRegexIndex = findModeQuery.regexMatches.length - 1; +    } +    var query = findModeQuery.regexMatches[findModeQuery.activeRegexIndex]; +  } +  else +    var query = findModeQuery.parsedQuery; + +  findModeQueryHasResults = executeFind(query, { backwards: backwards, caseSensitive: !findModeQuery.ignoreCase }); + +  // if we have found an input element via 'n', pressing <esc> immediately afterwards sends us into insert +  // mode +  var elementCanTakeInput = findModeQueryHasResults && domUtils.isSelectable(document.activeElement) && +    isDOMDescendant(findModeAnchorNode, document.activeElement); +  if (elementCanTakeInput) { +    handlerStack.push({ +      keydown: function(event) { +        handlerStack.pop(); +        if (isEscape(event)) { +          domUtils.simulateSelect(document.activeElement); +          enterInsertModeWithoutShowingIndicator(document.activeElement); +          return false; // we have 'consumed' this event, so do not propagate +        } +        return true; +      } +    }); +  } +    focusFoundLink();  } @@ -661,24 +868,83 @@ function performBackwardsFind() { findAndFocus(true); }  function getLinkFromSelection() {    var node = window.getSelection().anchorNode; -  while (node.nodeName.toLowerCase() !== 'body') { +  while (node && node.nodeName.toLowerCase() !== 'body') {      if (node.nodeName.toLowerCase() === 'a') return node;      node = node.parentNode;    }    return null;  } +// used by the findAndFollow* functions. +function followLink(link) { +  link.scrollIntoView(); +  link.focus(); +  domUtils.simulateClick(link); +} + +/** + * Find and follow the shortest link (shortest == fewest words) which matches any one of a list of strings. + * If there are multiple shortest links, strings are prioritized for exact word matches, followed by their + * position in :linkStrings.  Practically speaking, this means we favor 'next page' over 'the next big thing', + * and 'more' over 'nextcompany', even if 'next' occurs before 'more' in :linkStrings. + */  function findAndFollowLink(linkStrings) { -  for (i = 0; i < linkStrings.length; i++) { -    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; +  var linksXPath = domUtils.makeXPath(["a", "*[@onclick or @role='link']"]); +  var links = domUtils.evaluateXPath(linksXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE); +  var shortestLinks = []; +  var shortestLinkLength = null; + +  // at the end of this loop, shortestLinks will be populated with a list of candidates +  // links lower in the page are more likely to be the ones we want, so we loop through the snapshot backwards +  for (var i = links.snapshotLength - 1; i >= 0; i--) { +    var link = links.snapshotItem(i); + +    // ensure link is visible (we don't mind if it is scrolled offscreen) +    var boundingClientRect = link.getBoundingClientRect(); +    if (boundingClientRect.width == 0 || boundingClientRect.height == 0) +      continue; +    var computedStyle = window.getComputedStyle(link, null); +    if (computedStyle.getPropertyValue('visibility') != 'visible' || +        computedStyle.getPropertyValue('display') == 'none') +      continue; + +    var linkMatches = false; +    for (var j = 0; j < linkStrings.length; j++) { +      if (link.innerText.toLowerCase().indexOf(linkStrings[j]) !== -1) { +        linkMatches = true; +        break;        }      } +    if (!linkMatches) continue; + +    var wordCount = link.innerText.trim().split(/\s+/).length; +    if (shortestLinkLength === null || wordCount < shortestLinkLength) { +      shortestLinkLength = wordCount; +      shortestLinks = [ link ]; +    } +    else if (wordCount === shortestLinkLength) { +      shortestLinks.push(link); +    }    } + +  // try to get exact word matches first +  for (var i = 0; i < linkStrings.length; i++) +    for (var j = 0; j < shortestLinks.length; j++) { +      var exactWordRegex = new RegExp("\\b" + linkStrings[i] + "\\b", "i"); +      if (exactWordRegex.test(shortestLinks[j].innerText)) { +        followLink(shortestLinks[j]); +        return true; +      } +    } + +  for (var i = 0; i < linkStrings.length; i++) +    for (var j = 0; j < shortestLinks.length; j++) { +      if (shortestLinks[j].innerText.toLowerCase().indexOf(linkStrings[i]) !== -1) { +        followLink(shortestLinks[j]); +        return true; +      } +    } +    return false;  } @@ -688,7 +954,7 @@ function findAndFollowRel(value) {      var elements = document.getElementsByTagName(relTags[i]);      for (j = 0; j < elements.length; j++) {        if (elements[j].hasAttribute('rel') && elements[j].rel == value) { -        window.location = elements[j].href; +        followLink(elements[j]);          return true;        }      } @@ -708,37 +974,29 @@ function goNext() {  }  function showFindModeHUDForQuery() { -  if (findModeQueryHasResults || findModeQuery.length == 0) -    HUD.show("/" + insertSpaces(findModeQuery)); +  if (findModeQueryHasResults || findModeQuery.parsedQuery.length == 0) +    HUD.show("/" + insertSpaces(findModeQuery.rawQuery));    else -    HUD.show("/" + insertSpaces(findModeQuery + " (No Matches)")); +    HUD.show("/" + insertSpaces(findModeQuery.rawQuery + " (No Matches)"));  }  /*   * We need this so that the find mode HUD doesn't match its own searches.   */  function insertSpaces(query) { -  var newQuery = ""; - -  for (var i = 0; i < query.length; i++) { -    if (query[i] == " " || (i + 1 < query.length && query[i + 1] == " ")) -      newQuery = newQuery + query[i]; -    else //  ​ is a zero-width space -      newQuery = newQuery + query[i] + "<span>​</span>"; -  } - -  return newQuery; +  // ​ is a zero-width space. the <span>s are necessary because the zero-width space tends to interfere +  // with subsequent characters in the same text node. +  return query.split("").join("<span class='vimiumReset'>​</span>");  }  function enterFindMode() { -  findModeQuery = ""; +  findModeQuery = { rawQuery: "" };    findMode = true;    HUD.show("/");  }  function exitFindMode() {    findMode = false; -  focusFoundLink();    HUD.hide();  } @@ -772,6 +1030,15 @@ function hideHelpDialog(clickEvent) {      clickEvent.preventDefault();  } +// do our best to return the document to its 'default' state. +function handleEscapeForNormalMode() { +  window.getSelection().collapse(); +  if (document.activeElement !== document.body) +    document.activeElement.blur(); +  else if (window.top !== window.self) +    chrome.extension.sendRequest({ handler: "focusTopFrame" }); +} +  /*   * A heads-up-display (HUD) for showing Vimium page operations.   * Note: you cannot interact with the HUD until document.body is available. | 
