diff options
49 files changed, 2574 insertions, 1301 deletions
| @@ -42,5 +42,6 @@ Contributors:    Werner Laurensse (github: ab3)    Timo Sand <timo.j.sand@gmail.com> (github: deiga)    Shiyong Chen <billbill290@gmail.com> (github: UncleBill) +  Utkarsh Upadhyay <musically.ut@gmail.com) (github: musically-ut)  Feel free to add real names in addition to GitHub usernames. @@ -45,6 +45,7 @@ Navigating the current page:      yy      copy the current url to the clipboard      yf      copy a link url to the clipboard      gf      cycle forward to the next frame +    gF      focus the main/top frame  Navigating to new pages: @@ -88,6 +89,8 @@ Additional advanced browsing commands:      gU      go up to root of the URL hierarchy      zH      scroll all the way left      zL      scroll all the way right +    v       enter visual mode; use p/P to paste-and-go, use y to yank +    V       enter visual line mode  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. @@ -126,6 +129,7 @@ The following special keys are available for mapping:  - `<c-*>`, `<a-*>`, `<m-*>` for ctrl, alt, and meta (command on Mac) respectively with any key. Replace `*`    with the key of choice.  - `<left>`, `<right>`, `<up>`, `<down>` for the arrow keys +- `<space>` for the space key  - `<f1>` through `<f12>` for the function keys  Shifts are automatically detected so, for example, `<c-&>` corresponds to ctrl+shift+7 on an English keyboard. @@ -140,6 +144,30 @@ Please see [CONTRIBUTING.md](https://github.com/philc/vimium/blob/master/CONTRIB  Release Notes  ------------- +1.52 (not yet released) + +- Improved custom-search engine experience (including completion for Google, +  Youtube, Bing, DuckDuckGo, Wikipedia, Amazon and a number of other search +  engines). +- Bug fixes: bookmarklets accessed from the vomnibar. + +1.51 (2015-05-02) + +- Bug [fixes](https://github.com/philc/vimium/pulls?utf8=%E2%9C%93&q=is%3Apr+sort%3Aupdated-desc+is%3Aclosed+merged%3A%3E%3D2015-04-26+merged%3A%3C2015-05-02+state%3Amerged). + +1.50 (2015-04-26) + +- Visual mode (in beta): use `v` and then vim-like keystrokes to select text on the page. Use `y` to yank or +  `p` and `P` to search with your default search engine.. Please provide feedback on Github. +- Added the option to prevent pages from stealing focus from Vimium when loaded. +- Many bugfixes for custom search engines, and search engines can now have a description. +- Better support for frames: key exclusion rules are much improved and work within frames; the Vomnibar is +  always activated in the main frame; and a new command (`gF`) focuses the main frame. +- Find mode now has history. Use the up arrow to select previous searches. +- Ctrl and Shift when using link hints changes the tab in which links are opened in (reinstated feature). +- Focus input (`gi`) remembers previously-visited inputs. +- Bug fixes. +  1.49 (2014-12-16)  - An option to toggle smooth scrolling. @@ -174,7 +202,7 @@ Release Notes  - Added `gU`, which goes to the root of the current URL.  - Added `yt`, which duplicates the current tab.  - Added `W`, which moves the current tab to a new window. -- Added marks for saving and jumping to sections of a page. `mX` to set a mark and `X` to return to it. +- Added marks for saving and jumping to sections of a page. `mX` to set a mark and `` `X`` to return to it.  - Added "LinkHints.activateModeToOpenIncognito", currently an advanced, unbound command.  - Disallowed repeat tab closings, since this causes trouble for many people.  - Update our Chrome APIs so Vimium works on Chrome 28+. diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index 79cb9ee0..64ec36be 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -24,21 +24,13 @@ Commands =        noRepeat: options.noRepeat        repeatLimit: options.repeatLimit -  mapKeyToCommand: (key, command) -> +  mapKeyToCommand: ({ key, command, options }) ->      unless @availableCommands[command] -      console.log(command, "doesn't exist!") +      console.log command, "doesn't exist!"        return -    commandDetails = @availableCommands[command] - -    @keyToCommandRegistry[key] = -      command: command -      isBackgroundCommand: commandDetails.isBackgroundCommand -      passCountToFunction: commandDetails.passCountToFunction -      noRepeat: commandDetails.noRepeat -      repeatLimit: commandDetails.repeatLimit - -  unmapKey: (key) -> delete @keyToCommandRegistry[key] +    options ?= [] +    @keyToCommandRegistry[key] = extend { command, options }, @availableCommands[command]    # Lower-case the appropriate portions of named keys.    # @@ -52,39 +44,32 @@ Commands =      key.replace(/<[acm]-/ig, (match) -> match.toLowerCase())         .replace(/<([acm]-)?([a-zA-Z0-9]{2,5})>/g, (match, optionalPrefix, keyName) ->            "<" + (if optionalPrefix then optionalPrefix else "") + keyName.toLowerCase() + ">") +       .replace /<space>/ig, " "    parseCustomKeyMappings: (customKeyMappings) -> -    lines = customKeyMappings.split("\n") - -    for line in lines -      continue if (line[0] == "\"" || line[0] == "#") -      splitLine = line.replace(/\s+$/, "").split(/\s+/) - -      lineCommand = splitLine[0] - -      if (lineCommand == "map") -        continue if (splitLine.length != 3) -        key = @normalizeKey(splitLine[1]) -        vimiumCommand = splitLine[2] - -        continue unless @availableCommands[vimiumCommand] - -        console.log("Mapping", key, "to", vimiumCommand) -        @mapKeyToCommand(key, vimiumCommand) -      else if (lineCommand == "unmap") -        continue if (splitLine.length != 2) - -        key = @normalizeKey(splitLine[1]) -        console.log("Unmapping", key) -        @unmapKey(key) -      else if (lineCommand == "unmapAll") -        @keyToCommandRegistry = {} +    for line in customKeyMappings.split "\n" +      unless  line[0] == "\"" or line[0] == "#" +        tokens = line.replace(/\s+$/, "").split /\s+/ +        switch tokens[0] +          when "map" +            [ _, key, command, options... ] = tokens +            if command? and @availableCommands[command] +              key = @normalizeKey key +              console.log "Mapping", key, "to", command +              @mapKeyToCommand { key, command, options } + +          when "unmap" +            if tokens.length == 2 +              key = @normalizeKey tokens[1] +              console.log "Unmapping", key +              delete @keyToCommandRegistry[key] + +          when "unmapAll" +            @keyToCommandRegistry = {}    clearKeyMappingsAndSetDefaults: ->      @keyToCommandRegistry = {} - -    for key of defaultKeyMappings -      @mapKeyToCommand(key, defaultKeyMappings[key]) +    @mapKeyToCommand { key, command } for key, command of defaultKeyMappings    # An ordered listing of all available commands, grouped by type. This is the order they will    # be shown in the help page. @@ -121,18 +106,20 @@ Commands =        "LinkHints.activateModeWithQueue",        "LinkHints.activateModeToDownloadLink",        "LinkHints.activateModeToOpenIncognito", -      "Vomnibar.activate", -      "Vomnibar.activateInNewTab", -      "Vomnibar.activateTabSelection", -      "Vomnibar.activateBookmarks", -      "Vomnibar.activateBookmarksInNewTab",        "goPrevious",        "goNext",        "nextFrame", +      "mainFrame",        "Marks.activateCreateMode", -      "Vomnibar.activateEditUrl", -      "Vomnibar.activateEditUrlInNewTab",        "Marks.activateGotoMode"] +    vomnibarCommands: +      ["Vomnibar.activate", +      "Vomnibar.activateInNewTab", +      "Vomnibar.activateTabSelection", +      "Vomnibar.activateBookmarks", +      "Vomnibar.activateBookmarksInNewTab", +      "Vomnibar.activateEditUrl", +      "Vomnibar.activateEditUrlInNewTab"]      findCommands: ["enterFindMode", "performFind", "performBackwardsFind"]      historyNavigation:        ["goBack", "goForward"] @@ -255,6 +242,7 @@ defaultKeyMappings =    "gE": "Vomnibar.activateEditUrlInNewTab"    "gf": "nextFrame" +  "gF": "mainFrame"    "m": "Marks.activateCreateMode"    "`": "Marks.activateGotoMode" @@ -289,8 +277,8 @@ commandDescriptions =    openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true, repeatLimit: 20 }]    enterInsertMode: ["Enter insert mode", { noRepeat: true }] -  enterVisualMode: ["Enter visual mode (not yet implemented)", { noRepeat: true }] -  enterVisualLineMode: ["Enter visual line mode (not yet implemented)", { noRepeat: true }] +  enterVisualMode: ["Enter visual mode (beta feature)", { noRepeat: true }] +  enterVisualLineMode: ["Enter visual line mode (beta feature)", { noRepeat: true }]    # enterEditMode: ["Enter vim-like edit mode (not yet implemented)", { noRepeat: true }]    focusInput: ["Focus the first text box on the page. Cycle between them using tab", @@ -319,8 +307,8 @@ commandDescriptions =    goToRoot: ["Go to root of current URL hierarchy", { passCountToFunction: true }]    # Manipulating tabs -  nextTab: ["Go one tab right", { background: true }] -  previousTab: ["Go one tab left", { background: true }] +  nextTab: ["Go one tab right", { background: true, passCountToFunction: true }] +  previousTab: ["Go one tab left", { background: true, passCountToFunction: true }]    firstTab: ["Go to the first tab", { background: true }]    lastTab: ["Go to the last tab", { background: true }] @@ -350,11 +338,18 @@ commandDescriptions =    "Vomnibar.activateEditUrlInNewTab": ["Edit the current URL and open in a new tab", { noRepeat: true }]    nextFrame: ["Cycle forward to the next frame on the page", { background: true, passCountToFunction: true }] +  mainFrame: ["Select the tab's main/top frame", { background: true, noRepeat: true }]    "Marks.activateCreateMode": ["Create a new mark", { noRepeat: true }]    "Marks.activateGotoMode": ["Go to a mark", { noRepeat: true }]  Commands.init() +# Register postUpdateHook for keyMappings setting. +Settings.postUpdateHooks["keyMappings"] = (value) -> +  Commands.clearKeyMappingsAndSetDefaults() +  Commands.parseCustomKeyMappings value +  refreshCompletionKeysAfterMappingSave() +  root = exports ? window  root.Commands = Commands diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index 177892fb..c83066a6 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -5,42 +5,71 @@  # The Vomnibox frontend script makes a "filterCompleter" request to the background page, which in turn calls  # filter() on each these completers.  # -# A completer is a class which has two functions: +# A completer is a class which has three functions:  #  - filter(query, onComplete): "query" will be whatever the user typed into the Vomnibox.  #  - refresh(): (optional) refreshes the completer's data source (e.g. refetches the list of bookmarks). - -# A Suggestion is a bookmark or history entry which matches the current query. -# It also has an attached "computeRelevancyFunction" which determines how well this item matches the given -# query terms. +#  - cancel(): (optional) cancels any pending, cancelable action.  class Suggestion    showRelevancy: false # Set this to true to render relevancy when debugging the ranking scores. -  # - type: one of [bookmark, history, tab]. -  # - computeRelevancyFunction: a function which takes a Suggestion and returns a relevancy score -  #   between [0, 1] -  # - extraRelevancyData: data (like the History item itself) which may be used by the relevancy function. -  constructor: (@queryTerms, @type, @url, @title, @computeRelevancyFunction, @extraRelevancyData) -> -    @title ||= "" -    # When @autoSelect is truthy, the suggestion is automatically pre-selected in the vomnibar. +  constructor: (@options) -> +    # Required options. +    @queryTerms = null +    @type = null +    @url = null +    @relevancyFunction = null +    # Other options. +    @title = "" +    # Extra data which will be available to the relevancy function. +    @relevancyData = null +    # If @autoSelect is truthy, then this suggestion is automatically pre-selected in the vomnibar.  This only +    # affects the suggestion in slot 0 in the vomnibar.      @autoSelect = false +    # If @highlightTerms is true, then we highlight matched terms in the title and URL.  Otherwise we don't. +    @highlightTerms = true +    # @insertText is text to insert into the vomnibar input when the suggestion is selected. +    @insertText = null +    # @deDuplicate controls whether this suggestion is a candidate for deduplication. +    @deDuplicate = true + +    # Other options set by individual completers include: +    # - tabId (TabCompleter) +    # - isSearchSuggestion, customSearchMode (SearchEngineCompleter) + +    extend this, @options -  computeRelevancy: -> @relevancy = @computeRelevancyFunction(this) +  computeRelevancy: -> +    # We assume that, once the relevancy has been set, it won't change.  Completers must set either @relevancy +    # or @relevancyFunction. +    @relevancy ?= @relevancyFunction this -  generateHtml: -> +  generateHtml: (request) ->      return @html if @html      relevancyHtml = if @showRelevancy then "<span class='relevancy'>#{@computeRelevancy()}</span>" else "" +    insertTextClass = if @insertText then "vomnibarInsertText" else "vomnibarNoInsertText" +    insertTextIndicator = "↪" # A right hooked arrow. +    @title = @insertText if @insertText and request.isCustomSearch      # NOTE(philc): We're using these vimium-specific class names so we don't collide with the page's CSS.      @html = -      """ -      <div class="vimiumReset vomnibarTopHalf"> -         <span class="vimiumReset vomnibarSource">#{@type}</span> -         <span class="vimiumReset vomnibarTitle">#{@highlightTerms(Utils.escapeHtml(@title))}</span> -       </div> -       <div class="vimiumReset vomnibarBottomHalf"> -        <span class="vimiumReset vomnibarUrl">#{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))}</span> -        #{relevancyHtml} -      </div> -      """ +      if request.isCustomSearch +        """ +        <div class="vimiumReset vomnibarTopHalf"> +           <span class="vimiumReset vomnibarSource #{insertTextClass}">#{insertTextIndicator}</span><span class="vimiumReset vomnibarSource">#{@type}</span> +           <span class="vimiumReset vomnibarTitle">#{@highlightQueryTerms Utils.escapeHtml @title}</span> +           #{relevancyHtml} +         </div> +        """ +      else +        """ +        <div class="vimiumReset vomnibarTopHalf"> +           <span class="vimiumReset vomnibarSource #{insertTextClass}">#{insertTextIndicator}</span><span class="vimiumReset vomnibarSource">#{@type}</span> +           <span class="vimiumReset vomnibarTitle">#{@highlightQueryTerms Utils.escapeHtml @title}</span> +         </div> +         <div class="vimiumReset vomnibarBottomHalf"> +          <span class="vimiumReset vomnibarSource vomnibarNoInsertText">#{insertTextIndicator}</span><span class="vimiumReset vomnibarUrl">#{@highlightUrlTerms Utils.escapeHtml @shortenUrl()}</span> +          #{relevancyHtml} +        </div> +        """    # Use neat trick to snatch a domain (http://stackoverflow.com/a/8498668).    getUrlRoot: (url) -> @@ -48,7 +77,10 @@ class Suggestion      a.href = url      a.protocol + "//" + a.hostname -  shortenUrl: (url) -> @stripTrailingSlash(url).replace(/^https?:\/\//, "") +  getHostname: (url) -> +    a = document.createElement 'a' +    a.href = url +    a.hostname    stripTrailingSlash: (url) ->      url = url.substring(url, url.length - 1) if url[url.length - 1] == "/" @@ -77,7 +109,8 @@ class Suggestion        textPosition += matchedText.length    # Wraps each occurence of the query terms in the given string in a <span>. -  highlightTerms: (string) -> +  highlightQueryTerms: (string) -> +    return string unless @highlightTerms      ranges = []      escapedTerms = @queryTerms.map (term) -> Utils.escapeHtml(term)      for term in escapedTerms @@ -95,6 +128,9 @@ class Suggestion          string.substring(end)      string +  highlightUrlTerms: (string) -> +    if @highlightTermsExcludeUrl then string else @highlightQueryTerms string +    # Merges the given list of ranges such that any overlapping regions are combined. E.g.    #   mergeRanges([0, 4], [3, 6]) => [0, 6].  A range is [startIndex, endIndex].    mergeRanges: (ranges) -> @@ -108,6 +144,41 @@ class Suggestion          previous = range      mergedRanges +  # Simplify a suggestion's URL (by removing those parts which aren't useful for display or comparison). +  shortenUrl: () -> +    return @shortUrl if @shortUrl? +    # We get easier-to-read shortened URLs if we URI-decode them. +    url = (Utils.decodeURIByParts(@url) || @url).toLowerCase() +    for [ filter, replacements ] in @stripPatterns +      if new RegExp(filter).test url +        for replace in replacements +          url = url.replace replace, "" +    @shortUrl = url + +  # Patterns to strip from URLs; of the form [ [ filter, replacements ], [ filter, replacements ], ... ] +  #   - filter is a regexp string; a URL must match this regexp first. +  #   - replacements (itself a list) is a list of regexp objects, each of which is removed from URLs matching +  #     the filter. +  # +  # Note. This includes site-specific patterns for very-popular sites with URLs which don't work well in the +  # vomnibar. +  # +  stripPatterns: [ +    # Google search specific replacements; this replaces query parameters which are known to not be helpful. +    # There's some additional information here: http://www.teknoids.net/content/google-search-parameters-2012 +    [ "^https?://www\.google\.(com|ca|com\.au|co\.uk|ie)/.*[&?]q=" +      "ei gws_rd url ved usg sa usg sig2 bih biw cd aqs ie sourceid es_sm" +        .split(/\s+/).map (param) -> new RegExp "\&#{param}=[^&]+" ] + +    # General replacements; replaces leading and trailing fluff. +    [ '.', [ "^https?://", "\\W+$" ].map (re) -> new RegExp re ] +  ] + +  # Boost a relevancy score by a factor (in the range (0,1.0)), while keeping the score in the range [0,1]. +  # This makes greater adjustments to scores near the middle of the range (so, very poor relevancy scores +  # remain very poor). +  @boostRelevancyScore: (factor, score) -> +    score + if score < 0.5 then score * factor else (1.0 - score) * factor  class BookmarkCompleter    folderSeparator: "/" @@ -115,7 +186,7 @@ class BookmarkCompleter    # These bookmarks are loaded asynchronously when refresh() is called.    bookmarks: null -  filter: (@queryTerms, @onComplete) -> +  filter: ({ @queryTerms }, @onComplete) ->      @currentSearch = { queryTerms: @queryTerms, onComplete: @onComplete }      @performSearch() if @bookmarks @@ -133,11 +204,15 @@ class BookmarkCompleter        else          []      suggestions = results.map (bookmark) => -      suggestionTitle = if usePathAndTitle then bookmark.pathAndTitle else bookmark.title -      new Suggestion(@currentSearch.queryTerms, "bookmark", bookmark.url, suggestionTitle, @computeRelevancy) +      new Suggestion +        queryTerms: @currentSearch.queryTerms +        type: "bookmark" +        url: bookmark.url +        title: if usePathAndTitle then bookmark.pathAndTitle else bookmark.title +        relevancyFunction: @computeRelevancy      onComplete = @currentSearch.onComplete      @currentSearch = null -    onComplete(suggestions) +    onComplete suggestions    refresh: ->      @bookmarks = null @@ -172,27 +247,35 @@ class BookmarkCompleter      RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title)  class HistoryCompleter -  filter: (queryTerms, onComplete) -> +  filter: ({ queryTerms, seenTabToOpenCompletionList }, onComplete) ->      @currentSearch = { queryTerms: @queryTerms, onComplete: @onComplete }      results = []      HistoryCache.use (history) =>        results =          if queryTerms.length > 0            history.filter (entry) -> RankingUtils.matches(queryTerms, entry.url, entry.title) +        else if seenTabToOpenCompletionList +          # <Tab> opens the completion list, even without a query. +          history          else            [] -      suggestions = results.map (entry) => -        new Suggestion(queryTerms, "history", entry.url, entry.title, @computeRelevancy, entry) -      onComplete(suggestions) +      onComplete results.map (entry) => +        new Suggestion +          queryTerms: queryTerms +          type: "history" +          url: entry.url +          title: entry.title +          relevancyFunction: @computeRelevancy +          relevancyData: entry    computeRelevancy: (suggestion) -> -    historyEntry = suggestion.extraRelevancyData +    historyEntry = suggestion.relevancyData      recencyScore = RankingUtils.recencyScore(historyEntry.lastVisitTime) +    # If there are no query terms, then relevancy is based on recency alone. +    return recencyScore if suggestion.queryTerms.length == 0      wordRelevancy = RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title)      # Average out the word score and the recency. Recency has the ability to pull the score up, but not down. -    score = (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2 - -  refresh: -> +    (wordRelevancy + Math.max recencyScore, wordRelevancy) / 2  # The domain completer is designed to match a single-word query which looks like it is a domain. This supports  # the user experience where they quickly type a partial domain, hit tab -> enter, and expect to arrive there. @@ -203,8 +286,9 @@ class DomainCompleter    #     If `referenceCount` goes to zero, the domain entry can and should be deleted.    domains: null -  filter: (queryTerms, onComplete) -> -    return onComplete([]) if queryTerms.length > 1 +  filter: ({ queryTerms, query }, onComplete) -> +    # Do not offer completions if the query is empty, or if the user has finished typing the first word. +    return onComplete [] if queryTerms.length == 0 or /\S\s/.test query      if @domains        @performSearch(queryTerms, onComplete)      else @@ -212,20 +296,24 @@ class DomainCompleter    performSearch: (queryTerms, onComplete) ->      query = queryTerms[0] -    domainCandidates = (domain for domain of @domains when domain.indexOf(query) >= 0) -    domains = @sortDomainsByRelevancy(queryTerms, domainCandidates) -    return onComplete([]) if domains.length == 0 -    topDomain = domains[0][0] -    onComplete([new Suggestion(queryTerms, "domain", topDomain, null, @computeRelevancy)]) +    domains = (domain for domain of @domains when 0 <= domain.indexOf query) +    domains = @sortDomainsByRelevancy queryTerms, domains +    onComplete [ +      new Suggestion +        queryTerms: queryTerms +        type: "domain" +        url: domains[0]?[0] ? "" # This is the URL or an empty string, but not null. +        relevancy: 2.0 +      ].filter (s) -> 0 < s.url.length    # Returns a list of domains of the form: [ [domain, relevancy], ... ]    sortDomainsByRelevancy: (queryTerms, domainCandidates) -> -    results = [] -    for domain in domainCandidates -      recencyScore = RankingUtils.recencyScore(@domains[domain].entry.lastVisitTime || 0) -      wordRelevancy = RankingUtils.wordRelevancy(queryTerms, domain, null) -      score = (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2 -      results.push([domain, score]) +    results = +      for domain in domainCandidates +        recencyScore = RankingUtils.recencyScore(@domains[domain].entry.lastVisitTime || 0) +        wordRelevancy = RankingUtils.wordRelevancy queryTerms, domain, null +        score = (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2 +        [domain, score]      results.sort (a, b) -> b[1] - a[1]      results @@ -258,9 +346,6 @@ class DomainCompleter    parseDomainAndScheme: (url) ->        Utils.hasFullUrlPrefix(url) and not Utils.hasChromePrefix(url) and url.split("/",3).join "/" -  # Suggestions from the Domain completer have the maximum relevancy. They should be shown first in the list. -  computeRelevancy: -> 1 -  # TabRecency associates a logical timestamp with each tab id.  These are used to provide an initial  # recency-based ordering in the tabs vomnibar (which allows jumping quickly between recently-visited tabs).  class TabRecency @@ -304,16 +389,21 @@ tabRecency = new TabRecency()  # Searches through all open tabs, matching on title and URL.  class TabCompleter -  filter: (queryTerms, onComplete) -> +  filter: ({ queryTerms }, onComplete) ->      # NOTE(philc): We search all tabs, not just those in the current window. I'm not sure if this is the      # correct UX.      chrome.tabs.query {}, (tabs) =>        results = tabs.filter (tab) -> RankingUtils.matches(queryTerms, tab.url, tab.title)        suggestions = results.map (tab) => -        suggestion = new Suggestion(queryTerms, "tab", tab.url, tab.title, @computeRelevancy) -        suggestion.tabId = tab.id -        suggestion -      onComplete(suggestions) +        new Suggestion +          queryTerms: queryTerms +          type: "tab" +          url: tab.url +          title: tab.title +          relevancyFunction: @computeRelevancy +          tabId: tab.id +          deDuplicate: false +      onComplete suggestions    computeRelevancy: (suggestion) ->      if suggestion.queryTerms.length @@ -321,66 +411,232 @@ class TabCompleter      else        tabRecency.recencyScore(suggestion.tabId) -# A completer which will return your search engines  class SearchEngineCompleter -  searchEngines: {} - -  filter: (queryTerms, onComplete) -> -    {url: url, description: description} = @getSearchEngineMatches queryTerms -    suggestions = [] -    if url -      url = url.replace(/%s/g, Utils.createSearchQuery queryTerms[1..]) -      if description -        type = description -        query = queryTerms[1..].join " " +  @debug: false +  previousSuggestions: null + +  cancel: -> +    CompletionSearch.cancel() + +  # This looks up the custom search engine and, if one is found, notes it and removes its keyword from the +  # query terms. +  preprocessRequest: (request) -> +    SearchEngines.use (engines) => +      { queryTerms, query } = request +      extend request, searchEngines: engines, keywords: key for own key of engines +      keyword = queryTerms[0] +      # Note. For a keyword "w", we match "w search terms" and "w ", but not "w" on its own. +      if keyword and engines[keyword] and (1 < queryTerms.length or /\S\s/.test query) +        extend request, +          queryTerms: queryTerms[1..] +          keyword: keyword +          engine: engines[keyword] +          isCustomSearch: true + +  refresh: (port) -> +    @previousSuggestions = {} +    SearchEngines.refreshAndUse Settings.get("searchEngines"), (engines) -> +      # Let the front-end vomnibar know the search-engine keywords.  It needs to know them so that, when the +      # query goes from "w" to "w ", the vomnibar can synchronously launch the next filter() request (which +      # avoids an ugly delay/flicker). +      port.postMessage +        handler: "keywords" +        keywords: key for own key of engines + +  filter: (request, onComplete) -> +    { queryTerms, query, engine } = request +    return onComplete [] unless engine + +    { keyword, searchUrl, description } = engine +    extend request, searchUrl, customSearchMode: true + +    factor = 0.5 +    haveCompletionEngine = CompletionSearch.haveCompletionEngine searchUrl + +    # This filter is applied to all of the suggestions from all of the completers, after they have been +    # aggregated by the MultiCompleter. +    filter = (suggestions) -> +      suggestions.filter (suggestion) -> +        # We only keep suggestions which either *were* generated by this search engine, or *could have +        # been* generated by this search engine (and match the current query). +        suggestion.isSearchSuggestion or suggestion.isCustomSearch or +          ( +            terms = Utils.extractQuery searchUrl, suggestion.url +            terms and RankingUtils.matches queryTerms, terms +          ) + +    # If a previous suggestion still matches the query, then we keep it (even if the completion engine may not +    # return it for the current query).  This allows the user to pick suggestions by typing fragments of their +    # text, without regard to whether the completion engine can complete the actual text of the query. +    previousSuggestions = +      if queryTerms.length == 0 +        []        else -        type = "search" -        query = queryTerms[0] + ": " + queryTerms[1..].join(" ") -      suggestion = new Suggestion(queryTerms, type, url, query, @computeRelevancy) -      suggestion.autoSelect = true -      suggestions.push(suggestion) -    onComplete(suggestions) - -  computeRelevancy: -> 1 - -  refresh: -> -    this.searchEngines = root.Settings.getSearchEngines() - -  getSearchEngineMatches: (queryTerms) -> -    (1 < queryTerms.length and @searchEngines[queryTerms[0]]) or {} +        for url, suggestion of @previousSuggestions +          continue unless RankingUtils.matches queryTerms, suggestion.title +          # Reset various fields, they may not be correct wrt. the current query. +          extend suggestion, relevancy: null, html: null, queryTerms: queryTerms +          suggestion.relevancy = null +          suggestion + +    primarySuggestion = new Suggestion +      queryTerms: queryTerms +      type: description +      url: Utils.createSearchUrl queryTerms, searchUrl +      title: queryTerms.join " " +      relevancy: 2.0 +      autoSelect: true +      highlightTerms: false +      isSearchSuggestion: true +      isPrimarySuggestion: true + +    return onComplete [ primarySuggestion ], { filter } if queryTerms.length == 0 + +    mkSuggestion = do => +      count = 0 +      (suggestion) => +        url = Utils.createSearchUrl suggestion, searchUrl +        @previousSuggestions[url] = new Suggestion +          queryTerms: queryTerms +          type: description +          url: url +          title: suggestion +          insertText: suggestion +          highlightTerms: false +          highlightTermsExcludeUrl: true +          isCustomSearch: true +          relevancy: if ++count == 1 then 1.0 else null +          relevancyFunction: @computeRelevancy + +    cachedSuggestions = +      if haveCompletionEngine then CompletionSearch.complete searchUrl, queryTerms else null + +    suggestions = previousSuggestions +    suggestions.push primarySuggestion + +    if queryTerms.length == 0 or cachedSuggestions? or not haveCompletionEngine +      # There is no prospect of adding further completions, so we're done. +      suggestions.push cachedSuggestions.map(mkSuggestion)... if cachedSuggestions? +      onComplete suggestions, { filter, continuation: null } +    else +      # Post the initial suggestions, but then deliver any further completions asynchronously, as a +      # continuation. +      onComplete suggestions, +        filter: filter +        continuation: (onComplete) => +          CompletionSearch.complete searchUrl, queryTerms, (suggestions = []) => +            console.log "fetched suggestions:", suggestions.length, query if SearchEngineCompleter.debug +            onComplete suggestions.map mkSuggestion + +  computeRelevancy: ({ relevancyData, queryTerms, title }) -> +    # Tweaks: +    # - Calibration: we boost relevancy scores to try to achieve an appropriate balance between relevancy +    #   scores here, and those provided by other completers. +    # - Relevancy depends only on the title (which is the search terms), and not on the URL. +    Suggestion.boostRelevancyScore 0.5, +      0.7 * RankingUtils.wordRelevancy queryTerms, title, title + +  postProcessSuggestions: (request, suggestions) -> +    return unless request.searchEngines +    engines = (engine for _, engine of request.searchEngines) +    engines.sort (a,b) -> b.searchUrl.length - a.searchUrl.length +    engines.push keyword: null, description: "search history", searchUrl: Settings.get "searchUrl" +    for suggestion in suggestions +      unless suggestion.isSearchSuggestion or suggestion.insertText +        for engine in engines +          if suggestion.insertText = Utils.extractQuery engine.searchUrl, suggestion.url +            # suggestion.customSearchMode informs the vomnibar that, if the users edits the text from this +            # suggestion, then custom search-engine mode should be activated. +            suggestion.customSearchMode = engine.keyword +            suggestion.title ||= suggestion.insertText +            break  # A completer which calls filter() on many completers, aggregates the results, ranks them, and returns the top -# 10. Queries from the vomnibar frontend script come through a multi completer. +# 10. All queries from the vomnibar come through a multi completer.  class MultiCompleter -  constructor: (@completers) -> @maxResults = 10 +  maxResults: 10 +  filterInProgress: false +  mostRecentQuery: null -  refresh: -> completer.refresh() for completer in @completers when completer.refresh +  constructor: (@completers) -> +  refresh: (port) -> completer.refresh? port for completer in @completers +  cancel: (port) -> completer.cancel? port for completer in @completers -  filter: (queryTerms, onComplete) -> +  filter: (request, onComplete) ->      # Allow only one query to run at a time. -    if @filterInProgress -      @mostRecentQuery = { queryTerms: queryTerms, onComplete: onComplete } -      return +    return @mostRecentQuery = arguments if @filterInProgress + +    # Provide each completer with an opportunity to see (and possibly alter) the request before it is +    # launched. +    completer.preprocessRequest? request for completer in @completers +      RegexpCache.clear() -    @mostRecentQuery = null -    @filterInProgress = true -    suggestions = [] -    completersFinished = 0 -    for completer in @completers -      # Call filter() on every source completer and wait for them all to finish before returning results. -      completer.filter queryTerms, (newSuggestions) => -        suggestions = suggestions.concat(newSuggestions) -        completersFinished += 1 -        if completersFinished >= @completers.length -          results = @sortSuggestions(suggestions)[0...@maxResults] -          result.generateHtml() for result in results -          onComplete(results) -          @filterInProgress = false -          @filter(@mostRecentQuery.queryTerms, @mostRecentQuery.onComplete) if @mostRecentQuery - -  sortSuggestions: (suggestions) -> -    suggestion.computeRelevancy(@queryTerms) for suggestion in suggestions +    { queryTerms } = request + +    [ @mostRecentQuery, @filterInProgress ] = [ null, true ] +    [ suggestions, continuations, filters ] = [ [], [], [] ] + +    # Run each of the completers (asynchronously). +    jobs = new JobRunner @completers.map (completer) -> +      (callback) -> +        completer.filter request, (newSuggestions = [], { continuation, filter } = {}) -> +          suggestions.push newSuggestions... +          continuations.push continuation if continuation? +          filters.push filter if filter? +          callback() + +    # Once all completers have finished, process the results and post them, and run any continuations or a +    # pending query. +    jobs.onReady => +      suggestions = filter suggestions for filter in filters +      shouldRunContinuations = 0 < continuations.length and not @mostRecentQuery? + +      # Post results, unless there are none and we will be running a continuation.  This avoids +      # collapsing the vomnibar briefly before expanding it again, which looks ugly. +      unless suggestions.length == 0 and shouldRunContinuations +        suggestions = @prepareSuggestions request, queryTerms, suggestions +        onComplete results: suggestions + +      # Run any continuations (asynchronously); for example, the search-engine completer +      # (SearchEngineCompleter) uses a continuation to fetch suggestions from completion engines +      # asynchronously. +      if shouldRunContinuations +        jobs = new JobRunner continuations.map (continuation) -> +          (callback) -> +            continuation (newSuggestions) -> +              suggestions.push newSuggestions... +              callback() + +        jobs.onReady => +          suggestions = filter suggestions for filter in filters +          suggestions = @prepareSuggestions request, queryTerms, suggestions +          onComplete results: suggestions + +      # Admit subsequent queries and launch any pending query. +      @filterInProgress = false +      if @mostRecentQuery +        @filter @mostRecentQuery... + +  prepareSuggestions: (request, queryTerms, suggestions) -> +    # Compute suggestion relevancies and sort. +    suggestion.computeRelevancy queryTerms for suggestion in suggestions      suggestions.sort (a, b) -> b.relevancy - a.relevancy + +    # Simplify URLs and remove duplicates (duplicate simplified URLs, that is). +    count = 0 +    seenUrls = {} +    suggestions = +      for suggestion in suggestions +        url = suggestion.shortenUrl() +        continue if suggestion.deDuplicate and seenUrls[url] +        break if count++ == @maxResults +        seenUrls[url] = suggestion + +    # Give each completer the opportunity to tweak the suggestions. +    completer.postProcessSuggestions? request, suggestions for completer in @completers + +    # Generate HTML for the remaining suggestions and return them. +    suggestion.generateHtml request for suggestion in suggestions      suggestions  # Utilities which help us compute a relevancy score for a given item. @@ -529,8 +785,7 @@ HistoryCache =      @callbacks = null    use: (callback) -> -    return @fetchHistory(callback) unless @history? -    callback(@history) +    if @history? then callback @history else @fetchHistory callback    fetchHistory: (callback) ->      return @callbacks.push(callback) if @callbacks diff --git a/background_scripts/completion_engines.coffee b/background_scripts/completion_engines.coffee new file mode 100644 index 00000000..f15e6db4 --- /dev/null +++ b/background_scripts/completion_engines.coffee @@ -0,0 +1,135 @@ + +# A completion engine provides search suggestions for a search engine.  A search engine is identified by a +# "searchUrl", e.g. Settings.get("searchUrl"), or a custom search engine URL. +# +# Each completion engine defines three functions: +# +#   1. "match" - This takes a searchUrl and returns a boolean indicating whether this completion engine can +#      perform completion for the given search engine. +# +#   2. "getUrl" - This takes a list of query terms (queryTerms) and generates a completion URL, that is, a URL +#      which will provide completions for this completion engine. +# +#   3. "parse" - This takes a successful XMLHttpRequest object (the request has completed successfully), and +#      returns a list of suggestions (a list of strings).  This method is always executed within the context +#      of a try/catch block, so errors do not propagate. +# +# Each new completion engine must be added to the list "CompletionEngines" at the bottom of this file. +# +# The lookup logic which uses these completion engines is in "./completion_search.coffee". +# + +# A base class for common regexp-based matching engines. +class RegexpEngine +  constructor: (args...) -> @regexps = args.map (regexp) -> new RegExp regexp +  match: (searchUrl) -> Utils.matchesAnyRegexp @regexps, searchUrl + +# Several Google completion engines package XML responses in this way. +class GoogleXMLRegexpEngine extends RegexpEngine +  parse: (xhr) -> +    for suggestion in xhr.responseXML.getElementsByTagName "suggestion" +      continue unless suggestion = suggestion.getAttribute "data" +      suggestion + +class Google extends GoogleXMLRegexpEngine +  # Example search URL: http://www.google.com/search?q=%s +  constructor: (regexps = null) -> +    super regexps ? "^https?://[a-z]+\.google\.(com|ie|co\.uk|ca|com\.au)/" + +  getUrl: (queryTerms) -> +    Utils.createSearchUrl queryTerms, +      "http://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=%s" + +# A wrapper class for Google completions.  This adds prefix terms to the query, and strips those terms from +# the resulting suggestions.  For example, for Google Maps, we add "map of" as a prefix, then strip "map of" +# from the resulting suggestions. +class GoogleWithPrefix +  constructor: (prefix, args...) -> +    @engine = new Google args... +    @prefix = "#{prefix.trim()} " +    @queryTerms = @prefix.split /\s+/ +  match: (args...) -> @engine.match args... +  getUrl: (queryTerms) -> @engine.getUrl [ @queryTerms..., queryTerms... ] +  parse: (xhr) -> +    @engine.parse(xhr) +      .filter (suggestion) => suggestion.startsWith @prefix +      .map (suggestion) => suggestion[@prefix.length..].ltrim() + +# For Google Maps, we add the prefix "map of" to the query, and send it to Google's general search engine, +# then strip "map of" from the resulting suggestions. +class GoogleMaps extends GoogleWithPrefix +  # Example search URL: https://www.google.com/maps?q=%s +  constructor: -> super "map of", "https?://[a-z]+\.google\.(com|ie|co\.uk|ca|com\.au)/maps" + +class Youtube extends GoogleXMLRegexpEngine +  # Example search URL: http://www.youtube.com/results?search_query=%s +  constructor: -> +    super "^https?://[a-z]+\.youtube\.com/results" + +  getUrl: (queryTerms) -> +    Utils.createSearchUrl queryTerms, +      "http://suggestqueries.google.com/complete/search?client=youtube&ds=yt&xml=t&q=%s" + +class Wikipedia extends RegexpEngine +  # Example search URL: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s +  constructor: -> +    super "^https?://[a-z]+\.wikipedia\.org/" + +  getUrl: (queryTerms) -> +    Utils.createSearchUrl queryTerms, +      "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=%s" + +  parse: (xhr) -> +    JSON.parse(xhr.responseText)[1] + +class Bing extends RegexpEngine +  # Example search URL: https://www.bing.com/search?q=%s +  constructor: -> super "^https?://www\.bing\.com/search" +  getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "http://api.bing.com/osjson.aspx?query=%s" +  parse: (xhr) -> JSON.parse(xhr.responseText)[1] + +class Amazon extends RegexpEngine +  # Example search URL: http://www.amazon.com/s/?field-keywords=%s +  constructor: -> super "^https?://www\.amazon\.(com|co.uk|ca|com.au)/s/" +  getUrl: (queryTerms) -> +    Utils.createSearchUrl queryTerms, +      "https://completion.amazon.com/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=1&q=%s" +  parse: (xhr) -> JSON.parse(xhr.responseText)[1] + +class DuckDuckGo extends RegexpEngine +  # Example search URL: https://duckduckgo.com/?q=%s +  constructor: -> super "^https?://([a-z]+\.)?duckduckgo\.com/" +  getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "https://duckduckgo.com/ac/?q=%s" +  parse: (xhr) -> +    suggestion.phrase for suggestion in JSON.parse xhr.responseText + +class Webster extends RegexpEngine +  # Example search URL: http://www.merriam-webster.com/dictionary/%s +  constructor: -> super "^https?://www.merriam-webster.com/dictionary/" +  getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "http://www.merriam-webster.com/autocomplete?query=%s" +  parse: (xhr) -> JSON.parse(xhr.responseText).suggestions + +# A dummy search engine which is guaranteed to match any search URL, but never produces completions.  This +# allows the rest of the logic to be written knowing that there will always be a completion engine match. +class DummyCompletionEngine +  dummy: true +  match: -> true +  # We return a useless URL which we know will succeed, but which won't generate any network traffic. +  getUrl: -> chrome.runtime.getURL "content_scripts/vimium.css" +  parse: -> [] + +# Note: Order matters here. +CompletionEngines = [ +  Youtube +  GoogleMaps +  Google +  DuckDuckGo +  Wikipedia +  Bing +  Amazon +  Webster +  DummyCompletionEngine +] + +root = exports ? window +root.CompletionEngines = CompletionEngines diff --git a/background_scripts/completion_search.coffee b/background_scripts/completion_search.coffee new file mode 100644 index 00000000..d89eb278 --- /dev/null +++ b/background_scripts/completion_search.coffee @@ -0,0 +1,139 @@ + +CompletionSearch = +  debug: false +  inTransit: {} +  completionCache: new SimpleCache 2 * 60 * 60 * 1000, 5000 # Two hours, 5000 entries. +  engineCache:new SimpleCache 1000 * 60 * 60 * 1000 # 1000 hours. + +  # The amount of time to wait for new requests before launching the current request (for example, if the user +  # is still typing). +  delay: 100 + +  get: (searchUrl, url, callback) -> +    xhr = new XMLHttpRequest() +    xhr.open "GET", url, true +    xhr.timeout = 1000 +    xhr.ontimeout = xhr.onerror = -> callback null +    xhr.send() + +    xhr.onreadystatechange = -> +      if xhr.readyState == 4 +        callback if xhr.status == 200 then xhr else null + +  # Look up the completion engine for this searchUrl.  Because of DummyCompletionEngine, we know there will +  # always be a match. +  lookupEngine: (searchUrl) -> +    if @engineCache.has searchUrl +      @engineCache.get searchUrl +    else +      for engine in CompletionEngines +        engine = new engine() +        return @engineCache.set searchUrl, engine if engine.match searchUrl + +  # True if we have a completion engine for this search URL, false otherwise. +  haveCompletionEngine: (searchUrl) -> +    not @lookupEngine(searchUrl).dummy + +  # This is the main entry point. +  #  - searchUrl is the search engine's URL, e.g. Settings.get("searchUrl"), or a custom search engine's URL. +  #    This is only used as a key for determining the relevant completion engine. +  #  - queryTerms are the query terms. +  #  - callback will be applied to a list of suggestion strings (which may be an empty list, if anything goes +  #    wrong). +  # +  # If no callback is provided, then we're to provide suggestions only if we can do so synchronously (ie. +  # from a cache).  In this case we just return the results.  Returns null if we cannot service the request +  # synchronously. +  # +  complete: (searchUrl, queryTerms, callback = null) -> +    query = queryTerms.join(" ").toLowerCase() + +    returnResultsOnlyFromCache = not callback? +    callback ?= (suggestions) -> suggestions + +    # We don't complete queries which are too short: the results are usually useless. +    return callback [] unless 3 < query.length + +    # We don't complete regular URLs or Javascript URLs. +    return callback [] if 1 == queryTerms.length and Utils.isUrl query +    return callback [] if Utils.hasJavascriptPrefix query + +    # Cache completions.  However, completions depend upon both the searchUrl and the query terms.  So we need +    # to generate a key.  We mix in some junk generated by pwgen. A key clash might be possible, but +    # is vanishingly unlikely. +    junk = "//Zi?ei5;o//" +    completionCacheKey = searchUrl + junk + queryTerms.map((s) -> s.toLowerCase()).join junk + +    if @completionCache.has completionCacheKey +      console.log "hit", completionCacheKey if @debug +      return callback @completionCache.get completionCacheKey + +    # If the user appears to be typing a continuation of the characters of the most recent query, then we can +    # sometimes re-use the previous suggestions. +    if @mostRecentQuery? and @mostRecentSuggestions? +      reusePreviousSuggestions = do => +        # Verify that the previous query is a prefix of the current query. +        return false unless 0 == query.indexOf @mostRecentQuery.toLowerCase() +        # Verify that every previous suggestion contains the text of the new query. +        # Note: @mostRecentSuggestions may also be empty, in which case we drop though. The effect is that +        # previous queries with no suggestions suppress subsequent no-hope HTTP requests as the user continues +        # to type. +        for suggestion in @mostRecentSuggestions +          return false unless 0 <= suggestion.indexOf query +        # Ok. Re-use the suggestion. +        true + +      if reusePreviousSuggestions +        console.log "reuse previous query:", @mostRecentQuery, @mostRecentSuggestions.length if @debug +        return callback @completionCache.set completionCacheKey, @mostRecentSuggestions + +    # That's all of the caches we can try.  Bail if the caller is only requesting synchronous results.  We +    # signal that we haven't found a match by returning null. +    return callback null if returnResultsOnlyFromCache + +    # We pause in case the user is still typing. +    Utils.setTimeout @delay, handler = @mostRecentHandler = => +      if handler == @mostRecentHandler +        @mostRecentHandler = null + +        # Elide duplicate requests. First fetch the suggestions... +        @inTransit[completionCacheKey] ?= new AsyncDataFetcher (callback) => +          engine = @lookupEngine searchUrl +          url = engine.getUrl queryTerms + +          @get searchUrl, url, (xhr = null) => +            # Parsing the response may fail if we receive an unexpected or an unexpectedly-formatted response. +            # In all cases, we fall back to the catch clause, below.  Therefore, we "fail safe" in the case of +            # incorrect or out-of-date completion engines. +            try +              suggestions = engine.parse xhr +              # Make all suggestions lower case.  It looks odd when suggestions from one completion engine are +              # upper case, and those from another are lower case. +              suggestions = (suggestion.toLowerCase() for suggestion in suggestions) +              # Filter out the query itself. It's not adding anything. +              suggestions = (suggestion for suggestion in suggestions when suggestion != query) +              console.log "GET", url if @debug +            catch +              suggestions = [] +              # We allow failures to be cached too, but remove them after just thirty minutes. +              Utils.setTimeout 30 * 60 * 1000, => @completionCache.set completionCacheKey, null +              console.log "fail", url if @debug + +            callback suggestions +            delete @inTransit[completionCacheKey] + +        # ... then use the suggestions. +        @inTransit[completionCacheKey].use (suggestions) => +          @mostRecentQuery = query +          @mostRecentSuggestions = suggestions +          callback @completionCache.set completionCacheKey, suggestions + +  # Cancel any pending (ie. blocked on @delay) queries.  Does not cancel in-flight queries.  This is called +  # whenever the user is typing. +  cancel: -> +    if @mostRecentHandler? +      @mostRecentHandler = null +      console.log "cancel (user is typing)" if @debug + +root = exports ? window +root.CompletionSearch = CompletionSearch diff --git a/background_scripts/exclusions.coffee b/background_scripts/exclusions.coffee index 55ced3ef..21342d61 100644 --- a/background_scripts/exclusions.coffee +++ b/background_scripts/exclusions.coffee @@ -2,6 +2,7 @@ root = exports ? window  RegexpCache =    cache: {} +  clear: -> @cache = {}    get: (pattern) ->      if regexp = @cache[pattern]        regexp @@ -31,9 +32,11 @@ root.Exclusions = Exclusions =      # An absolute exclusion rule (with no passKeys) takes priority.      for rule in matches        return rule unless rule.passKeys +    # Strip whitespace from all matching passKeys strings, and join them together. +    passKeys = (rule.passKeys.split(/\s+/).join "" for rule in matches).join ""      if 0 < matches.length        pattern: (rule.pattern for rule in matches).join " | " # Not used; for debugging only. -      passKeys: Utils.distinctCharacters (rule.passKeys for rule in matches).join "" +      passKeys: Utils.distinctCharacters passKeys      else        null @@ -42,8 +45,8 @@ root.Exclusions = Exclusions =      @rules = rules.filter (rule) -> rule and rule.pattern      Settings.set("exclusionRules", @rules) -  postUpdateHook: (rules) -> -    @rules = rules +  postUpdateHook: (@rules) -> +    RegexpCache.clear()  # Development and debug only.  # Enable this (temporarily) to restore legacy exclusion rules from backup. @@ -70,3 +73,7 @@ if not Settings.has("exclusionRules") and Settings.has("excludedUrls")    # We'll keep a backup of the "excludedUrls" setting, just in case.    Settings.set("excludedUrlsBackup", Settings.get("excludedUrls")) if not Settings.has("excludedUrlsBackup")    Settings.clear("excludedUrls") + +# Register postUpdateHook for exclusionRules setting. +Settings.postUpdateHooks["exclusionRules"] = (value) -> +  Exclusions.postUpdateHook value diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index d034ffb0..99a5672b 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -1,7 +1,24 @@  root = exports ? window -currentVersion = Utils.getCurrentVersion() +# The browser may have tabs already open. We inject the content scripts immediately so that they work straight +# away. +chrome.runtime.onInstalled.addListener ({ reason }) -> +  # See https://developer.chrome.com/extensions/runtime#event-onInstalled +  return if reason in [ "chrome_update", "shared_module_update" ] +  manifest = chrome.runtime.getManifest() +  # Content scripts loaded on every page should be in the same group. We assume it is the first. +  contentScripts = manifest.content_scripts[0] +  jobs = [ [ chrome.tabs.executeScript, contentScripts.js ], [ chrome.tabs.insertCSS, contentScripts.css ] ] +  # Chrome complains if we don't evaluate chrome.runtime.lastError on errors (and we get errors for tabs on +  # which Vimium cannot run). +  checkLastRuntimeError = -> chrome.runtime.lastError +  chrome.tabs.query { status: "complete" }, (tabs) -> +    for tab in tabs +      for [ func, files ] in jobs +        for file in files +          func tab.id, { file: file, allFrames: contentScripts.all_frames }, checkLastRuntimeError +currentVersion = Utils.getCurrentVersion()  tabQueue = {} # windowId -> Array  tabInfoMap = {} # tabId -> object with various tab properties  keyQueue = "" # Queue of keys typed @@ -9,6 +26,7 @@ validFirstKeys = {}  singleKeyCommands = []  focusedFrame = null  frameIdsForTab = {} +root.urlForTab = {}  # Keys are either literal characters, or "named" - for example <a-b> (alt+b), <left> (left arrow) or <f12>  # This regular expression captures two groups: the first is a named key, the second is the remainder of @@ -25,22 +43,38 @@ chrome.storage.local.set    vimiumSecret: Math.floor Math.random() * 2000000000  completionSources = -  bookmarks: new BookmarkCompleter() -  history: new HistoryCompleter() -  domains: new DomainCompleter() -  tabs: new TabCompleter() -  seachEngines: new SearchEngineCompleter() +  bookmarks: new BookmarkCompleter +  history: new HistoryCompleter +  domains: new DomainCompleter +  tabs: new TabCompleter +  searchEngines: new SearchEngineCompleter  completers = -  omni: new MultiCompleter([ -    completionSources.seachEngines, -    completionSources.bookmarks, -    completionSources.history, -    completionSources.domains]) -  bookmarks: new MultiCompleter([completionSources.bookmarks]) -  tabs: new MultiCompleter([completionSources.tabs]) - -chrome.runtime.onConnect.addListener((port, name) -> +  omni: new MultiCompleter [ +    completionSources.bookmarks +    completionSources.history +    completionSources.domains +    completionSources.searchEngines +    ] +  bookmarks: new MultiCompleter [completionSources.bookmarks] +  tabs: new MultiCompleter [completionSources.tabs] + +completionHandlers = +  filter: (completer, request, port) -> +    completer.filter request, (response) -> +      # We use try here because this may fail if the sender has already navigated away from the original page. +      # This can happen, for example, when posting completion suggestions from the SearchEngineCompleter +      # (which is done asynchronously). +      try +        port.postMessage extend request, extend response, handler: "completions" + +  refresh: (completer, _, port) -> completer.refresh port +  cancel: (completer, _, port) -> completer.cancel port + +handleCompletions = (request, port) -> +  completionHandlers[request.handler] completers[request.name], request, port + +chrome.runtime.onConnect.addListener (port, name) ->    senderTabId = if port.sender.tab then port.sender.tab.id else null    # If this is a tab we've been waiting to open, execute any "tab loaded" handlers, e.g. to restore    # the tab's scroll position. Wait until domReady before doing this; otherwise operations like restoring @@ -52,14 +86,8 @@ chrome.runtime.onConnect.addListener((port, name) ->        delete tabLoadedHandlers[senderTabId]        toCall.call() -    # domReady is the appropriate time to show the "vimium has been upgraded" message. -    # TODO: This might be broken on pages with frames. -    if (shouldShowUpgradeMessage()) -      chrome.tabs.sendMessage(senderTabId, { name: "showUpgradeNotification", version: currentVersion }) -    if (portHandlers[port.name])      port.onMessage.addListener(portHandlers[port.name]) -)  chrome.runtime.onMessage.addListener((request, sender, sendResponse) ->    if (sendRequestHandlers[request.handler]) @@ -76,14 +104,24 @@ getCurrentTabUrl = (request, sender) -> sender.tab.url  #  # Checks the user's preferences in local storage to determine if Vimium is enabled for the given URL, and  # whether any keys should be passed through to the underlying page. +# The source frame also informs us whether or not it has the focus, which allows us to track the URL of the +# active frame.  # -root.isEnabledForUrl = isEnabledForUrl = (request) -> +root.isEnabledForUrl = isEnabledForUrl = (request, sender) -> +  urlForTab[sender.tab.id] = request.url if request.frameIsFocused    rule = Exclusions.getRule(request.url)    {      isEnabledForUrl: not rule or rule.passKeys      passKeys: rule?.passKeys or ""    } +onURLChange = (details) -> +  chrome.tabs.sendMessage details.tabId, name: "checkEnabledAfterURLChange" + +# Re-check whether Vimium is enabled for a frame when the url changes without a reload. +chrome.webNavigation.onHistoryStateUpdated.addListener onURLChange # history.pushState. +chrome.webNavigation.onReferenceFragmentUpdated.addListener onURLChange # Hash changed. +  # Retrieves the help dialog HTML template from a file, and populates it with the latest keybindings.  # This is called by options.coffee.  root.helpDialogHtml = (showUnboundCommands, showCommandNames, customTitle) -> @@ -149,22 +187,22 @@ openUrlInCurrentTab = (request) ->  #  # Opens request.url in new tab and switches to it if request.selected is true.  # -openUrlInNewTab = (request) -> -  chrome.tabs.getSelected(null, (tab) -> -    chrome.tabs.create({ url: Utils.convertToUrl(request.url), index: tab.index + 1, selected: true })) +openUrlInNewTab = (request, callback) -> +  chrome.tabs.getSelected null, (tab) -> +    tabConfig = +      url: Utils.convertToUrl request.url +      index: tab.index + 1 +      selected: true +      windowId: tab.windowId +    # FIXME(smblott). openUrlInNewTab is being called in two different ways with different arguments.  We +    # should refactor it such that this check on callback isn't necessary. +    callback = (->) unless typeof callback == "function" +    chrome.tabs.create tabConfig, callback  openUrlInIncognito = (request) ->    chrome.windows.create({ url: Utils.convertToUrl(request.url), incognito: true})  # -# Called when the user has clicked the close icon on the "Vimium has been updated" message. -# We should now dismiss that message in all tabs. -# -upgradeNotificationClosed = (request) -> -  Settings.set("previousVersion", currentVersion) -  sendRequestToAllTabs({ name: "hideUpgradeNotification" }) - -#  # Copies or pastes some data (request.data) to/from the clipboard.  # We return null to avoid the return value from the copy operations being passed to sendResponse.  # @@ -182,21 +220,16 @@ selectSpecificTab = (request) ->  #  # Used by the content scripts to get settings from the local storage.  # -handleSettings = (args, port) -> -  if (args.operation == "get") -    value = Settings.get(args.key) -    port.postMessage({ key: args.key, value: value }) -  else # operation == "set" -    Settings.set(args.key, args.value) - -refreshCompleter = (request) -> completers[request.name].refresh() - -whitespaceRegexp = /\s+/ -filterCompleter = (args, port) -> -  queryTerms = if (args.query == "") then [] else args.query.split(whitespaceRegexp) -  completers[args.name].filter(queryTerms, (results) -> port.postMessage({ id: args.id, results: results })) - -getCurrentTimeInSeconds = -> Math.floor((new Date()).getTime() / 1000) +handleSettings = (request, port) -> +  switch request.operation +    when "get" # Get a single settings value. +      port.postMessage key: request.key, value: Settings.get request.key +    when "set" # Set a single settings value. +      Settings.set request.key, request.value +    when "fetch" # Fetch multiple settings values. +      values = request.values +      values[key] = Settings.get key for own key of values +      port.postMessage { values }  chrome.tabs.onSelectionChanged.addListener (tabId, selectionInfo) ->    if (selectionChangedHandlers.length > 0) @@ -219,7 +252,14 @@ moveTab = (callback, direction) ->  # These are commands which are bound to keystroke which must be handled by the background page. They are  # mapped in commands.coffee.  BackgroundCommands = -  createTab: (callback) -> chrome.tabs.create({url: Settings.get("newTabUrl")}, (tab) -> callback()) +  createTab: (callback) -> +    chrome.tabs.query { active: true, currentWindow: true }, (tabs) -> +      tab = tabs[0] +      url = Settings.get "newTabUrl" +      if url == "pages/blank.html" +        # "pages/blank.html" does not work in incognito mode, so fall back to "chrome://newtab" instead. +        url = if tab.incognito then "chrome://newtab" else chrome.runtime.getURL url +      openUrlInNewTab { url }, callback    duplicateTab: (callback) ->      chrome.tabs.getSelected(null, (tab) ->        chrome.tabs.duplicate(tab.id) @@ -228,10 +268,10 @@ BackgroundCommands =      chrome.tabs.query {active: true, currentWindow: true}, (tabs) ->        tab = tabs[0]        chrome.windows.create {tabId: tab.id, incognito: tab.incognito} -  nextTab: (callback) -> selectTab(callback, "next") -  previousTab: (callback) -> selectTab(callback, "previous") -  firstTab: (callback) -> selectTab(callback, "first") -  lastTab: (callback) -> selectTab(callback, "last") +  nextTab: (count) -> selectTab "next", count +  previousTab: (count) -> selectTab "previous", count +  firstTab: -> selectTab "first" +  lastTab: -> selectTab "last"    removeTab: (callback) ->      chrome.tabs.getSelected(null, (tab) ->        chrome.tabs.remove(tab.id) @@ -271,13 +311,13 @@ BackgroundCommands =    moveTabLeft: (count) -> moveTab(null, -count)    moveTabRight: (count) -> moveTab(null, count)    nextFrame: (count,frameId) -> -    chrome.tabs.getSelected(null, (tab) -> -      frames = frameIdsForTab[tab.id] -      # We can't always track which frame chrome has focussed, but here we learn that it's frameId; so add an -      # additional offset such that we do indeed start from frameId. -      count = (count + Math.max 0, frameIdsForTab[tab.id].indexOf frameId) % frames.length -      frames = frameIdsForTab[tab.id] = [frames[count..]..., frames[0...count]...] -      chrome.tabs.sendMessage(tab.id, { name: "focusFrame", frameId: frames[0], highlight: true })) +    chrome.tabs.getSelected null, (tab) -> +      frameIdsForTab[tab.id] = cycleToFrame frameIdsForTab[tab.id], frameId, count +      chrome.tabs.sendMessage tab.id, name: "focusFrame", frameId: frameIdsForTab[tab.id][0], highlight: true +  mainFrame: -> +    chrome.tabs.getSelected null, (tab) -> +      # The front end interprets a frameId of 0 to mean the main/top from. +      chrome.tabs.sendMessage tab.id, name: "focusFrame", frameId: 0, highlight: true    closeTabsOnLeft: -> removeTabsRelative "before"    closeTabsOnRight: -> removeTabsRelative "after" @@ -305,21 +345,23 @@ removeTabsRelative = (direction) ->  # Selects a tab before or after the currently selected tab.  # - direction: "next", "previous", "first" or "last". -selectTab = (callback, direction) -> -  chrome.tabs.getAllInWindow(null, (tabs) -> +selectTab = (direction, count = 1) -> +  chrome.tabs.getAllInWindow null, (tabs) ->      return unless tabs.length > 1 -    chrome.tabs.getSelected(null, (currentTab) -> -      switch direction -        when "next" -          toSelect = tabs[(currentTab.index + 1 + tabs.length) % tabs.length] -        when "previous" -          toSelect = tabs[(currentTab.index - 1 + tabs.length) % tabs.length] -        when "first" -          toSelect = tabs[0] -        when "last" -          toSelect = tabs[tabs.length - 1] -      selectionChangedHandlers.push(callback) -      chrome.tabs.update(toSelect.id, { selected: true }))) +    chrome.tabs.getSelected null, (currentTab) -> +      toSelect = +        switch direction +          when "next" +            currentTab.index + count +          when "previous" +            currentTab.index - count +          when "first" +            0 +          when "last" +            tabs.length - 1 +      # Bring toSelect into the range [0,tabs.length). +      toSelect = (toSelect + tabs.length * Math.abs count) % tabs.length +      chrome.tabs.update tabs[toSelect].id, selected: true  updateOpenTabs = (tab, deleteFrames = false) ->    # Chrome might reuse the tab ID of a recently removed tab. @@ -335,60 +377,27 @@ updateOpenTabs = (tab, deleteFrames = false) ->    # Frames are recreated on refresh    delete frameIdsForTab[tab.id] if deleteFrames -setBrowserActionIcon = (tabId,path) -> -  chrome.browserAction.setIcon({ tabId: tabId, path: path }) - -chrome.browserAction.setBadgeBackgroundColor -  # This is Vimium blue (from the icon). -  # color: [102, 176, 226, 255] -  # This is a slightly darker blue. It makes the badge more striking in the corner of the eye, and the symbol -  # easier to read. -  color: [82, 156, 206, 255] - -setBadge = do -> -  current = null -  timer = null -  updateBadge = (badge) -> -> chrome.browserAction.setBadgeText text: badge -  (request) -> -    badge = request.badge -    if badge? and badge != current -      current = badge -      clearTimeout timer if timer -      # We wait a few moments. This avoids badge flicker when there are rapid changes. -      timer = setTimeout updateBadge(badge), 50 - -# Updates the browserAction icon to indicate whether Vimium is enabled or disabled on the current page. -# Also propagates new enabled/disabled/passkeys state to active window, if necessary. -# This lets you disable Vimium on a page without needing to reload. -# Exported via root because it's called from the page popup. -root.updateActiveState = updateActiveState = (tabId) -> -  enabledIcon = "icons/browser_action_enabled.png" -  disabledIcon = "icons/browser_action_disabled.png" -  partialIcon = "icons/browser_action_partial.png" -  chrome.tabs.get tabId, (tab) -> -    # Default to disabled state in case we can't connect to Vimium, primarily for the "New Tab" page. -    setBrowserActionIcon(tabId,disabledIcon) -    setBadge badge: "" -    chrome.tabs.sendMessage tabId, { name: "getActiveState" }, (response) -> -      if response -        isCurrentlyEnabled = response.enabled -        currentPasskeys = response.passKeys -        config = isEnabledForUrl({url: tab.url}) -        enabled = config.isEnabledForUrl -        passKeys = config.passKeys -        if (enabled and passKeys) -          setBrowserActionIcon(tabId,partialIcon) -        else if (enabled) -          setBrowserActionIcon(tabId,enabledIcon) -        else -          setBrowserActionIcon(tabId,disabledIcon) -        # Propagate the new state only if it has changed. -        if (isCurrentlyEnabled != enabled || currentPasskeys != passKeys) -          chrome.tabs.sendMessage(tabId, { name: "setState", enabled: enabled, passKeys: passKeys, incognito: tab.incognito }) - +# Here's how we set the page icon.  The default is "disabled", so if we do nothing else, then we get the +# grey-out disabled icon.  Thereafter, we only set tab-specific icons, so there's no need to update the icon +# when we visit a tab on which Vimium isn't running. +# +# For active tabs, when a frame starts, it requests its active state via isEnabledForUrl.  We also check the +# state every time a frame gets the focus.  In both cases, the frame then updates the tab's icon accordingly. +# +# Exclusion rule changes (from either the options page or the page popup) propagate via the subsequent focus +# change.  In particular, whenever a frame next gets the focus, it requests its new state and sets the icon +# accordingly. +# +setIcon = (request, sender) -> +  path = switch request.icon +    when "enabled" then "icons/browser_action_enabled.png" +    when "partial" then "icons/browser_action_partial.png" +    when "disabled" then "icons/browser_action_disabled.png" +  chrome.browserAction.setIcon tabId: sender.tab.id, path: path  handleUpdateScrollPosition = (request, sender) -> -  updateScrollPosition(sender.tab, request.scrollX, request.scrollY) +  # See note regarding sender.tab at unregisterFrame. +  updateScrollPosition sender.tab, request.scrollX, request.scrollY if sender.tab?  updateScrollPosition = (tab, scrollX, scrollY) ->    tabInfoMap[tab.id].scrollX = scrollX @@ -402,7 +411,6 @@ chrome.tabs.onUpdated.addListener (tabId, changeInfo, tab) ->      runAt: "document_start"    chrome.tabs.insertCSS tabId, cssConf, -> chrome.runtime.lastError    updateOpenTabs(tab) if changeInfo.url? -  updateActiveState(tabId)  chrome.tabs.onAttached.addListener (tabId, attachedInfo) ->    # We should update all the tabs in the old window and the new window. @@ -437,8 +445,7 @@ chrome.tabs.onRemoved.addListener (tabId) ->    tabInfoMap.deletor = -> delete tabInfoMap[tabId]    setTimeout tabInfoMap.deletor, 1000    delete frameIdsForTab[tabId] - -chrome.tabs.onActiveChanged.addListener (tabId, selectInfo) -> updateActiveState(tabId) +  delete urlForTab[tabId]  unless chrome.sessions    chrome.windows.onRemoved.addListener (windowId) -> delete tabQueue[windowId] @@ -551,13 +558,13 @@ checkKeyQueue = (keysToCheck, tabId, frameId) ->      if runCommand        if not registryEntry.isBackgroundCommand -        chrome.tabs.sendMessage(tabId, -          name: "executePageCommand", -          command: registryEntry.command, -          frameId: frameId, -          count: count, -          passCountToFunction: registryEntry.passCountToFunction, -          completionKeys: generateCompletionKeys("")) +        chrome.tabs.sendMessage tabId, +          name: "executePageCommand" +          command: registryEntry.command +          frameId: frameId +          count: count +          completionKeys: generateCompletionKeys "" +          registryEntry: registryEntry          refreshedCompletionKeys = true        else          if registryEntry.passCountToFunction @@ -595,16 +602,6 @@ sendRequestToAllTabs = (args) ->        for tab in window.tabs          chrome.tabs.sendMessage(tab.id, args, null)) -# -# Returns true if the current extension version is greater than the previously recorded version in -# localStorage, and false otherwise. -# -shouldShowUpgradeMessage = -> -  # Avoid showing the upgrade notification when previousVersion is undefined, which is the case for new -  # installs. -  Settings.set("previousVersion", currentVersion) unless Settings.get("previousVersion") -  Utils.compareVersions(currentVersion, Settings.get("previousVersion")) == 1 -  openOptionsPageInNewTab = ->    chrome.tabs.getSelected(null, (tab) ->      chrome.tabs.create({ url: chrome.runtime.getURL("pages/options.html"), index: tab.index + 1 })) @@ -613,7 +610,10 @@ registerFrame = (request, sender) ->    (frameIdsForTab[sender.tab.id] ?= []).push request.frameId  unregisterFrame = (request, sender) -> -  tabId = sender.tab.id +  # When a tab is closing, Chrome sometimes passes messages without sender.tab.  Therefore, we guard against +  # this. +  tabId = sender.tab?.id +  return unless tabId?    if frameIdsForTab[tabId]?      if request.tab_is_closing        updateOpenTabs sender.tab, true @@ -622,15 +622,36 @@ unregisterFrame = (request, sender) ->  handleFrameFocused = (request, sender) ->    tabId = sender.tab.id +  # Cycle frameIdsForTab to the focused frame.  However, also ensure that we don't inadvertently register a +  # frame which wasn't previously registered (such as a frameset).    if frameIdsForTab[tabId]? -    frameIdsForTab[tabId] = -      [request.frameId, (frameIdsForTab[tabId].filter (id) -> id != request.frameId)...] +    frameIdsForTab[tabId] = cycleToFrame frameIdsForTab[tabId], request.frameId +  # Inform all frames that a frame has received the focus. +  chrome.tabs.sendMessage sender.tab.id, +    name: "frameFocused" +    focusFrameId: request.frameId + +# Rotate through frames to the frame count places after frameId. +cycleToFrame = (frames, frameId, count = 0) -> +  frames ||= [] +  # We can't always track which frame chrome has focussed, but here we learn that it's frameId; so add an +  # additional offset such that we do indeed start from frameId. +  count = (count + Math.max 0, frames.indexOf frameId) % frames.length +  [frames[count..]..., frames[0...count]...] + +# Send a message to all frames in the current tab. +sendMessageToFrames = (request, sender) -> +  chrome.tabs.sendMessage sender.tab.id, request.message + +# For debugging only. This allows content scripts to log messages to the background page's console. +bgLog = (request, sender) -> +  console.log "#{sender.tab.id}/#{request.frameId}", request.message  # Port handler mapping  portHandlers =    keyDown: handleKeyDown,    settings: handleSettings, -  filterCompleter: filterCompleter +  completions: handleCompletions  sendRequestHandlers =    getCompletionKeys: getCompletionKeysRequest @@ -643,16 +664,16 @@ sendRequestHandlers =    unregisterFrame: unregisterFrame    frameFocused: handleFrameFocused    nextFrame: (request) -> BackgroundCommands.nextFrame 1, request.frameId -  upgradeNotificationClosed: upgradeNotificationClosed    updateScrollPosition: handleUpdateScrollPosition    copyToClipboard: copyToClipboard    pasteFromClipboard: pasteFromClipboard    isEnabledForUrl: isEnabledForUrl    selectSpecificTab: selectSpecificTab -  refreshCompleter: refreshCompleter    createMark: Marks.create.bind(Marks)    gotoMark: Marks.goto.bind(Marks) -  setBadge: setBadge +  setIcon: setIcon +  sendMessageToFrames: sendMessageToFrames +  log: bgLog  # We always remove chrome.storage.local/findModeRawQueryListIncognito on startup.  chrome.storage.local.remove "findModeRawQueryListIncognito" @@ -668,6 +689,13 @@ chrome.tabs.onRemoved.addListener (tabId) ->          # There are no remaining incognito-mode tabs, and findModeRawQueryListIncognito is set.          chrome.storage.local.remove "findModeRawQueryListIncognito" +# Tidy up tab caches when tabs are removed.  We cannot rely on unregisterFrame because Chrome does not always +# provide sender.tab there. +# NOTE(smblott) (2015-05-05) This may break restoreTab on legacy Chrome versions, but we'll be moving to +# chrome.sessions support only soon anyway. +chrome.tabs.onRemoved.addListener (tabId) -> +  delete cache[tabId] for cache in [ frameIdsForTab, urlForTab, tabInfoMap ] +  # Convenience function for development use.  window.runTests = -> open(chrome.runtime.getURL('tests/dom_tests/dom_tests.html')) @@ -681,8 +709,30 @@ if Settings.has("keyMappings")  populateValidFirstKeys()  populateSingleKeyCommands() -if shouldShowUpgradeMessage() -  sendRequestToAllTabs({ name: "showUpgradeNotification", version: currentVersion }) + +# Show notification on upgrade. +showUpgradeMessage = -> +  # Avoid showing the upgrade notification when previousVersion is undefined, which is the case for new +  # installs. +  Settings.set "previousVersion", currentVersion  unless Settings.get "previousVersion" +  if Utils.compareVersions(currentVersion, Settings.get "previousVersion" ) == 1 +    notificationId = "VimiumUpgradeNotification" +    notification = +      type: "basic" +      iconUrl: chrome.runtime.getURL "icons/vimium.png" +      title: "Vimium Upgrade" +      message: "Vimium has been upgraded to version #{currentVersion}. Click here for more information." +      isClickable: true +    if chrome.notifications?.create? +      chrome.notifications.create notificationId, notification, -> +        unless chrome.runtime.lastError +          Settings.set "previousVersion", currentVersion +          chrome.notifications.onClicked.addListener (id) -> +            if id == notificationId +              openUrlInNewTab url: "https://github.com/philc/vimium#release-notes" +    else +      # We need to wait for the user to accept the "notifications" permission. +      chrome.permissions.onAdded.addListener showUpgradeMessage  # Ensure that tabInfoMap is populated when Vimium is installed.  chrome.windows.getAll { populate: true }, (windows) -> @@ -694,4 +744,5 @@ chrome.windows.getAll { populate: true }, (windows) ->        chrome.tabs.sendMessage(tab.id, { name: "getScrollPosition" }, createScrollPositionHandler())  # Start pulling changes from synchronized storage. -Sync.init() +Settings.init() +showUpgradeMessage() diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee deleted file mode 100644 index 3528e8a9..00000000 --- a/background_scripts/settings.coffee +++ /dev/null @@ -1,133 +0,0 @@ -# -# Used by all parts of Vimium to manipulate localStorage. -# - -root = exports ? window -root.Settings = Settings = -  get: (key) -> -    if (key of localStorage) then JSON.parse(localStorage[key]) else @defaults[key] - -  set: (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 == @defaults[key]) -      @clear(key) -    else -      jsonValue = JSON.stringify value -      localStorage[key] = jsonValue -      Sync.set key, jsonValue - -  clear: (key) -> -    if @has key -      delete localStorage[key] -    Sync.clear key - -  has: (key) -> key of localStorage - -  # For settings which require action when their value changes, add hooks here called from -  # options/options.coffee (when the options page is saved), and from background_scripts/sync.coffee (when an -  # update propagates from chrome.storage.sync). -  postUpdateHooks: -    keyMappings: (value) -> -      root.Commands.clearKeyMappingsAndSetDefaults() -      root.Commands.parseCustomKeyMappings value -      root.refreshCompletionKeysAfterMappingSave() - -    searchEngines: (value) -> -      root.Settings.parseSearchEngines value - -    exclusionRules: (value) -> -      root.Exclusions.postUpdateHook value - -  # postUpdateHooks convenience wrapper -  performPostUpdateHook: (key, value) -> -    @postUpdateHooks[key] value if @postUpdateHooks[key] - -  # Here we have our functions that parse the search engines -  # this is a map that we use to store our search engines for use. -  searchEnginesMap: {} - -  # Parse the custom search engines setting and cache it. -  parseSearchEngines: (searchEnginesText) -> -    @searchEnginesMap = {} -    for line in searchEnginesText.split /\n/ -      tokens = line.trim().split /\s+/ -      continue if tokens.length < 2 or tokens[0].startsWith('"') or tokens[0].startsWith("#") -      keywords = tokens[0].split ":" -      continue unless keywords.length == 2 and not keywords[1] # So, like: [ "w", "" ]. -      @searchEnginesMap[keywords[0]] = -        url: tokens[1] -        description: tokens[2..].join(" ") - -  # Fetch the search-engine map, building it if necessary. -  getSearchEngines: -> -    this.parseSearchEngines(@get("searchEngines") || "") if Object.keys(@searchEnginesMap).length == 0 -    @searchEnginesMap - -  # options.coffee and options.html only handle booleans and strings; therefore all defaults must be booleans -  # or strings -  defaults: -    scrollStepSize: 60 -    smoothScroll: true -    keyMappings: "# Insert your preferred key mappings here." -    linkHintCharacters: "sadfjklewcmpgh" -    linkHintNumbers: "0123456789" -    filterLinkHints: false -    hideHud: false -    userDefinedLinkHintCss: -      """ -      div > .vimiumHintMarker { -      /* linkhint boxes */ -      background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785), -        color-stop(100%,#FFC542)); -      border: 1px solid #E3BE23; -      } - -      div > .vimiumHintMarker span { -      /* linkhint text */ -      color: black; -      font-weight: bold; -      font-size: 12px; -      } - -      div > .vimiumHintMarker > .matchingCharacter { -      } -      """ -    # Default exclusion rules. -    exclusionRules: -      [ -        # Disable Vimium on Gmail. -        { pattern: "http*://mail.google.com/*", passKeys: "" } -      ] - -    # 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,>>" -    # default/fall back search engine -    searchUrl: "http://www.google.com/search?q=" -    # put in an example search engine -    searchEngines: "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s wikipedia" -    newTabUrl: "chrome://newtab" -    grabBackFocus: false - -    settingsVersion: Utils.getCurrentVersion() - - -# We use settingsVersion to coordinate any necessary schema changes. -if Utils.compareVersions("1.42", Settings.get("settingsVersion")) != -1 -  Settings.set("scrollStepSize", parseFloat Settings.get("scrollStepSize")) -Settings.set("settingsVersion", Utils.getCurrentVersion()) - -# Migration (after 1.49, 2015/2/1). -# Legacy setting: findModeRawQuery (a string). -# New setting: findModeRawQueryList (a list of strings), now stored in chrome.storage.local (not localStorage). -chrome.storage.local.get "findModeRawQueryList", (items) -> -  unless chrome.runtime.lastError or items.findModeRawQueryList -    rawQuery = Settings.get "findModeRawQuery" -    chrome.storage.local.set findModeRawQueryList: (if rawQuery then [ rawQuery ] else []) - diff --git a/background_scripts/sync.coffee b/background_scripts/sync.coffee deleted file mode 100644 index d0d501d3..00000000 --- a/background_scripts/sync.coffee +++ /dev/null @@ -1,74 +0,0 @@ -# -# * Sync.set() and Sync.clear() propagate local changes to chrome.storage.sync. -# * Sync.handleStorageUpdate() listens for changes to chrome.storage.sync and propagates those -#   changes to localStorage and into vimium's internal state. -# * Sync.fetchAsync() polls chrome.storage.sync at startup, similarly propagating -#   changes to localStorage and into vimium's internal state. -# -# Changes are propagated into vimium's state using the same mechanism -# (Settings.performPostUpdateHook) that is used when options are changed on -# the options page. -# -# The effect is best-effort synchronization of vimium options/settings between -# chrome/vimium instances. -# -# NOTE: -#   Values handled within this module are ALWAYS already JSON.stringifed, so -#   they're always non-empty strings. -# - -root = exports ? window -root.Sync = Sync = - -  storage: chrome.storage.sync -  doNotSync: ["settingsVersion", "previousVersion"] - -  # This is called in main.coffee. -  init: -> -    chrome.storage.onChanged.addListener (changes, area) -> Sync.handleStorageUpdate changes, area -    @fetchAsync() - -  # Asynchronous fetch from synced storage, called only at startup. -  fetchAsync: -> -    @storage.get null, (items) => -      unless chrome.runtime.lastError -        for own key, value of items -          @storeAndPropagate key, value - -  # Asynchronous message from synced storage. -  handleStorageUpdate: (changes, area) -> -    for own key, change of changes -      @storeAndPropagate key, change?.newValue - -  # Only ever called from asynchronous synced-storage callbacks (fetchAsync and handleStorageUpdate). -  storeAndPropagate: (key, value) -> -    return unless key of Settings.defaults -    return if not @shouldSyncKey key -    return if value and key of localStorage and localStorage[key] is value -    defaultValue = Settings.defaults[key] -    defaultValueJSON = JSON.stringify(defaultValue) - -    if value and value != defaultValueJSON -      # Key/value has been changed to non-default value at remote instance. -      localStorage[key] = value -      Settings.performPostUpdateHook key, JSON.parse(value) -    else -      # Key has been reset to default value at remote instance. -      if key of localStorage -        delete localStorage[key] -      Settings.performPostUpdateHook key, defaultValue - -  # Only called synchronously from within vimium, never on a callback. -  # No need to propagate updates to the rest of vimium, that's already been done. -  set: (key, value) -> -    if @shouldSyncKey key -      setting = {}; setting[key] = value -      @storage.set setting - -  # Only called synchronously from within vimium, never on a callback. -  clear: (key) -> -    @storage.remove key if @shouldSyncKey key - -  # Should we synchronize this key? -  shouldSyncKey: (key) -> key not in @doNotSync - diff --git a/content_scripts/hud.coffee b/content_scripts/hud.coffee new file mode 100644 index 00000000..f38d6b45 --- /dev/null +++ b/content_scripts/hud.coffee @@ -0,0 +1,99 @@ +# +# A heads-up-display (HUD) for showing Vimium page operations. +# Note: you cannot interact with the HUD until document.body is available. +# +HUD = +  tween: null +  hudUI: null +  _displayElement: null + +  # This HUD is styled to precisely mimick the chrome HUD on Mac. Use the "has_popup_and_link_hud.html" +  # test harness to tweak these styles to match Chrome's. One limitation of our HUD display is that +  # it doesn't sit on top of horizontal scrollbars like Chrome's HUD does. + +  init: -> +    @hudUI = new UIComponent "pages/hud.html", "vimiumHUDFrame", ({data}) => +      this[data.name]? data +    @tween = new Tween "iframe.vimiumHUDFrame.vimiumUIComponentVisible", @hudUI.shadowDOM + +  showForDuration: (text, duration) -> +    @show(text) +    @_showForDurationTimerId = setTimeout((=> @hide()), duration) + +  show: (text) -> +    return unless @enabled() +    clearTimeout(@_showForDurationTimerId) +    @hudUI.show {name: "show", text} +    @tween.fade 1.0, 150 + +  # Hide the HUD. +  # If :immediate is falsy, then the HUD is faded out smoothly (otherwise it is hidden immediately). +  # If :updateIndicator is truthy, then we also refresh the mode indicator.  The only time we don't update the +  # mode indicator, is when hide() is called for the mode indicator itself. +  hide: (immediate = false, updateIndicator = true) -> +    return unless @tween? +    clearTimeout(@_showForDurationTimerId) +    @tween.stop() +    if immediate +      unless updateIndicator +        @hudUI.hide() +        @hudUI.postMessage {name: "hide"} +      Mode.setIndicator() if updateIndicator +    else +      @tween.fade 0, 150, => @hide true, updateIndicator + +  isReady: do -> +    ready = false +    DomUtils.documentReady -> ready = true +    -> ready and document.body != null + +  # A preference which can be toggled in the Options page. */ +  enabled: -> !settings.get("hideHud") + +class Tween +  opacity: 0 +  intervalId: -1 +  styleElement: null + +  constructor: (@cssSelector, insertionPoint = document.documentElement) -> +    @styleElement = document.createElement "style" + +    unless @styleElement.style +      # We're in an XML document, so we shouldn't inject any elements. See the comment in UIComponent. +      Tween::fade = Tween::stop = Tween::updateStyle = -> +      return + +    @styleElement.type = "text/css" +    @styleElement.innerHTML = "" +    insertionPoint.appendChild @styleElement + +  fade: (toAlpha, duration, onComplete) -> +    clearInterval @intervalId +    startTime = (new Date()).getTime() +    fromAlpha = @opacity +    alphaStep = toAlpha - fromAlpha + +    performStep = => +      elapsed = (new Date()).getTime() - startTime +      if (elapsed >= duration) +        clearInterval @intervalId +        @updateStyle toAlpha +        onComplete?() +      else +        value = (elapsed / duration) * alphaStep + fromAlpha +        @updateStyle value + +    @updateStyle @opacity +    @intervalId = setInterval performStep, 50 + +  stop: -> clearInterval @intervalId + +  updateStyle: (@opacity) -> +    @styleElement.innerHTML = """ +      #{@cssSelector} { +        opacity: #{@opacity}; +      } +    """ + +root = exports ? window +root.HUD = HUD diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee index 2abfa001..3cebac4c 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -8,16 +8,15 @@  # 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.  # -# The "name" property below is a short-form name to appear in the link-hints mode name.  Debugging only.  The -# key appears in the mode's badge. +# The "name" property below is a short-form name to appear in the link-hints mode's name.  It's for debug only.  # -OPEN_IN_CURRENT_TAB = { name: "curr-tab", key: "" } -OPEN_IN_NEW_BG_TAB = { name: "bg-tab", key: "B" } -OPEN_IN_NEW_FG_TAB = { name: "fg-tab", key: "F" } -OPEN_WITH_QUEUE = { name: "queue", key: "Q" } -COPY_LINK_URL = { name: "link", key: "C" } -OPEN_INCOGNITO = { name: "incognito", key: "I" } -DOWNLOAD_LINK_URL = { name: "download", key: "D" } +OPEN_IN_CURRENT_TAB = name: "curr-tab" +OPEN_IN_NEW_BG_TAB = name: "bg-tab" +OPEN_IN_NEW_FG_TAB = name: "fg-tab" +OPEN_WITH_QUEUE = name: "queue" +COPY_LINK_URL = name: "link" +OPEN_INCOGNITO = name: "incognito" +DOWNLOAD_LINK_URL = name: "download"  LinkHints =    hintMarkerContainingDiv: null @@ -33,6 +32,8 @@ LinkHints =      if settings.get("filterLinkHints") then filterHints else alphabetHints    # lock to ensure only one instance runs at a time    isActive: false +  # Call this function on exit (if defined). +  onExit: null    #    # To be called after linkHints has been generated from linkHintsBase. @@ -55,60 +56,72 @@ LinkHints =        return      @isActive = true -    @setOpenLinkMode(mode) -    hintMarkers = (@createMarkerFor(el) for el in @getVisibleClickableElements()) +    elements = @getVisibleClickableElements() +    # For these modes, we filter out those elements which don't have an HREF (since there's nothing we can do +    # with them). +    elements = (el for el in elements when el.element.href?) if mode in [ COPY_LINK_URL, OPEN_INCOGNITO ] +    if settings.get "filterLinkHints" +      # When using text filtering, we sort the elements such that we visit descendants before their ancestors. +      # This allows us to exclude the text used for matching descendants from that used for matching their +      # ancestors. +      length = (el) -> el.element.innerHTML?.length ? 0 +      elements.sort (a,b) -> length(a) - length b +    hintMarkers = (@createMarkerFor(el) for el in elements)      @getMarkerMatcher().fillInMarkers(hintMarkers) +    @hintMode = new Mode +      name: "hint/#{mode.name}" +      indicator: false +      passInitialKeyupEvents: true +      keydown: @onKeyDownInMode.bind this, hintMarkers +      # Trap all other key events. +      keypress: -> false +      keyup: -> false + +    @setOpenLinkMode mode +      # 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.      @hintMarkerContainingDiv = DomUtils.addElementList(hintMarkers,        { id: "vimiumHintMarkerContainer", className: "vimiumReset" }) -    @hintMode = new Mode -      name: "hint/#{mode.name}" -      badge: "#{mode.key}?" -      keydown: @onKeyDownInMode.bind(this, hintMarkers), -      # trap all key events -      keypress: -> false -      keyup: -> false -    setOpenLinkMode: (@mode) ->      if @mode is OPEN_IN_NEW_BG_TAB or @mode is OPEN_IN_NEW_FG_TAB or @mode is OPEN_WITH_QUEUE        if @mode is OPEN_IN_NEW_BG_TAB -        HUD.show("Open link in new tab") +        @hintMode.setIndicator "Open link in new tab"        else if @mode is OPEN_IN_NEW_FG_TAB -        HUD.show("Open link in new tab and switch to it") +        @hintMode.setIndicator "Open link in new tab and switch to it"        else -        HUD.show("Open multiple links in a new tab") +        @hintMode.setIndicator "Open multiple links in a new tab"        @linkActivator = (link) ->          # When "clicking" on a link, dispatch the event with the appropriate meta key (CMD on Mac, CTRL on          # windows) to open it in a new tab if necessary. -        DomUtils.simulateClick(link, { -          shiftKey: @mode is OPEN_IN_NEW_FG_TAB, -          metaKey: KeyboardUtils.platform == "Mac", -          ctrlKey: KeyboardUtils.platform != "Mac", -          altKey: false}) +        DomUtils.simulateClick link, +          shiftKey: @mode is OPEN_IN_NEW_FG_TAB +          metaKey: KeyboardUtils.platform == "Mac" +          ctrlKey: KeyboardUtils.platform != "Mac" +          altKey: false      else if @mode is COPY_LINK_URL -      HUD.show("Copy link URL to Clipboard") -      @linkActivator = (link) -> -        chrome.runtime.sendMessage({handler: "copyToClipboard", data: link.href}) +      @hintMode.setIndicator "Copy link URL to Clipboard" +      @linkActivator = (link) => +        if link.href? +          chrome.runtime.sendMessage handler: "copyToClipboard", data: link.href +          url = link.href +          url = url[0..25] + "...." if 28 < url.length +          @onExit = -> HUD.showForDuration "Yanked #{url}", 2000 +        else +          @onExit = -> HUD.showForDuration "No link to yank.", 2000      else if @mode is OPEN_INCOGNITO -      HUD.show("Open link in incognito window") - +      @hintMode.setIndicator "Open link in incognito window"        @linkActivator = (link) -> -        chrome.runtime.sendMessage( -          handler: 'openUrlInIncognito' -          url: link.href) +        chrome.runtime.sendMessage handler: 'openUrlInIncognito', url: link.href      else if @mode is DOWNLOAD_LINK_URL -      HUD.show("Download link URL") +      @hintMode.setIndicator "Download link URL"        @linkActivator = (link) -> -        DomUtils.simulateClick(link, { -          altKey: true, -          ctrlKey: false, -          metaKey: false }) +        DomUtils.simulateClick link, altKey: true, ctrlKey: false, metaKey: false      else # OPEN_IN_CURRENT_TAB -      HUD.show("Open link in current tab") +      @hintMode.setIndicator "Open link in current tab"        @linkActivator = (link) -> DomUtils.simulateClick.bind(DomUtils, link)()    # @@ -190,7 +203,7 @@ LinkHints =        isClickable = onlyHasTabIndex = true      if isClickable -      clientRect = DomUtils.getVisibleClientRect element +      clientRect = DomUtils.getVisibleClientRect element, true        if clientRect != null          visibleElements.push {element: element, rect: clientRect, secondClassCitizen: onlyHasTabIndex} @@ -253,7 +266,7 @@ LinkHints =    # Handles shift and esc keys. The other keys are passed to getMarkerMatcher().matchHintsByKey.    #    onKeyDownInMode: (hintMarkers, event) -> -    return if @delayMode +    return if @delayMode or event.repeat      if ((event.keyCode == keyCodes.shiftKey or event.keyCode == keyCodes.ctrlKey) and          (@mode == OPEN_IN_CURRENT_TAB or @@ -273,8 +286,8 @@ LinkHints =        handlerStack.push          keyup: (event) =>            if event.keyCode == keyCode -            @setOpenLinkMode previousMode if @isActive              handlerStack.remove() +            @setOpenLinkMode previousMode if @isActive            true      # TODO(philc): Ignore keys that have modifiers. @@ -307,7 +320,7 @@ LinkHints =        @deactivateMode(delay, -> LinkHints.delayMode = false)      else        # TODO figure out which other input elements should not receive focus -      if (clickEl.nodeName.toLowerCase() == "input" && clickEl.type != "button") +      if (clickEl.nodeName.toLowerCase() == "input" and clickEl.type not in ["button", "submit"])          clickEl.focus()        DomUtils.flashRect(matchedLink.rect)        @linkActivator(clickEl) @@ -344,7 +357,8 @@ LinkHints =          DomUtils.removeElement LinkHints.hintMarkerContainingDiv        LinkHints.hintMarkerContainingDiv = null        @hintMode.exit() -      HUD.hide() +      @onExit?() +      @onExit = null        @isActive = false      # we invoke the deactivate() function directly instead of using setTimeout(callback, 0) so that @@ -469,7 +483,7 @@ filterHints =        linkText = element.firstElementChild.alt || element.firstElementChild.title        showLinkText = true if (linkText)      else -      linkText = element.textContent || element.innerHTML +      linkText = DomUtils.textContent.get element      { text: linkText, show: showLinkText } @@ -479,6 +493,7 @@ filterHints =    fillInMarkers: (hintMarkers) ->      @generateLabelMap() +    DomUtils.textContent.reset()      for marker, idx in hintMarkers        marker.hintString = @generateHintString(idx)        linkTextObject = @generateLinkText(marker.clickableItem) diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee index 7877d97c..f631b4cd 100644 --- a/content_scripts/mode.coffee +++ b/content_scripts/mode.coffee @@ -6,13 +6,6 @@  # name:  #   A name for this mode.  # -# badge: -#   A badge (to appear on the browser popup). -#   Optional.  Define a badge if the badge is constant; for example, in find mode the badge is always "/". -#   Otherwise, do not define a badge, but instead override the updateBadge method; for example, in passkeys -#   mode, the badge may be "P" or "", depending on the configuration state.  Or, if the mode *never* shows a -#   badge, then do neither. -#  # keydown:  # keypress:  # keyup: @@ -48,7 +41,6 @@ class Mode      @handlers = []      @exitHandlers = []      @modeIsActive = true -    @badge = @options.badge || ""      @name = @options.name || "anonymous"      @count = ++count @@ -59,7 +51,16 @@ class Mode        keydown: @options.keydown || null        keypress: @options.keypress || null        keyup: @options.keyup || null -      updateBadge: (badge) => @alwaysContinueBubbling => @updateBadge badge +      indicator: => +        # Update the mode indicator.  Setting @options.indicator to a string shows a mode indicator in the +        # HUD.  Setting @options.indicator to 'false' forces no mode indicator.  If @options.indicator is +        # undefined, then the request propagates to the next mode. +        # The active indicator can also be changed with @setIndicator(). +        if @options.indicator? +          if HUD?.isReady() +            if @options.indicator then HUD.show @options.indicator else HUD.hide true, false +          @stopBubblingAndTrue +        else @continueBubbling      # If @options.exitOnEscape is truthy, then the mode will exit when the escape key is pressed.      if @options.exitOnEscape @@ -86,6 +87,13 @@ class Mode          _name: "mode-#{@id}/exitOnClick"          "click": (event) => @alwaysContinueBubbling => @exit event +    #If @options.exitOnFocus is truthy, then the mode will exit whenever a focusable element is activated. +    if @options.exitOnFocus +      @push +        _name: "mode-#{@id}/exitOnFocus" +        "focus": (event) => @alwaysContinueBubbling => +          @exit event if DomUtils.isFocusable event.target +      # Some modes are singletons: there may be at most one instance active at any time.  A mode is a singleton      # if @options.singleton is truthy.  The value of @options.singleton should be the key which is intended to      # be unique.  New instances deactivate existing instances with the same key. @@ -113,11 +121,28 @@ class Mode              @registerStateChange?()          registerKeyQueue: ({ keyQueue: keyQueue }) => @alwaysContinueBubbling => @keyQueue = keyQueue +    # If @options.passInitialKeyupEvents is set, then we pass initial non-printable keyup events to the page +    # or to other extensions (because the corresponding keydown events were passed).  This is used when +    # activating link hints, see #1522. +    if @options.passInitialKeyupEvents +      @push +        _name: "mode-#{@id}/passInitialKeyupEvents" +        keydown: => @alwaysContinueBubbling -> handlerStack.remove() +        keyup: (event) => +          if KeyboardUtils.isPrintable event then @stopBubblingAndFalse else @stopBubblingAndTrue +      Mode.modes.push @ -    Mode.updateBadge() +    @setIndicator()      @logModes()      # End of Mode constructor. +  setIndicator: (indicator = @options.indicator) -> +    @options.indicator = indicator +    Mode.setIndicator() + +  @setIndicator: -> +    handlerStack.bubbleEvent "indicator" +    push: (handlers) ->      handlers._name ||= "mode-#{@id}"      @handlers.push handlerStack.push handlers @@ -135,17 +160,12 @@ class Mode        handler() for handler in @exitHandlers        handlerStack.remove handlerId for handlerId in @handlers        Mode.modes = Mode.modes.filter (mode) => mode != @ -      Mode.updateBadge()        @modeIsActive = false +      @setIndicator()    deactivateSingleton: (singleton) ->      Mode.singletons?[Utils.getIdentity singleton]?.exit() -  # The badge is chosen by bubbling an "updateBadge" event down the handler stack allowing each mode the -  # opportunity to choose a badge. This is overridden in sub-classes. -  updateBadge: (badge) -> -    badge.badge ||= @badge -    # Shorthand for an otherwise long name.  This wraps a handler with an arbitrary return value, and always    # yields @continueBubbling instead.  This simplifies handlers if they always continue bubbling (a common    # case), because they do not need to be concerned with the value they yield. @@ -157,14 +177,6 @@ class Mode      delete @options[key] for key in [ "keydown", "keypress", "keyup" ]      new @constructor @options -  # Static method.  Used externally and internally to initiate bubbling of an updateBadge event and to send -  # the resulting badge to the background page.  We only update the badge if this document (hence this frame) -  # has the focus. -  @updateBadge: -> -    if document.hasFocus() -      handlerStack.bubbleEvent "updateBadge", badge = badge: "" -      chrome.runtime.sendMessage { handler: "setBadge", badge: badge.badge }, -> -    # Debugging routines.    logModes: ->      if @debug @@ -183,31 +195,5 @@ class Mode      mode.exit() for mode in @modes      @modes = [] -# BadgeMode is a pseudo mode for triggering badge updates on focus changes and state updates. It sits at the -# bottom of the handler stack, and so it receives state changes *after* all other modes, and can override the -# badge choice of the other modes. -class BadgeMode extends Mode -  constructor: () -> -    super -      name: "badge" -      trackState: true - -    # FIXME(smblott) BadgeMode is currently triggering an updateBadge event on every focus event.  That's a -    # lot, considerably more than necessary.  Really, it only needs to trigger when we change frame, or when -    # we change tab. -    @push -      _name: "mode-#{@id}/focus" -      "focus": => @alwaysContinueBubbling -> Mode.updateBadge() - -  updateBadge: (badge) -> -    # If we're not enabled, then post an empty badge. -    badge.badge = "" unless @enabled - -  # When the registerStateChange event bubbles to the bottom of the stack, all modes have been notified.  So -  # it's now time to update the badge. -  registerStateChange: -> -    Mode.updateBadge() -  root = exports ? window  root.Mode = Mode -root.BadgeMode = BadgeMode diff --git a/content_scripts/mode_find.coffee b/content_scripts/mode_find.coffee index 67f2a7dc..ed08fbd5 100644 --- a/content_scripts/mode_find.coffee +++ b/content_scripts/mode_find.coffee @@ -33,8 +33,6 @@ class PostFindMode extends SuppressPrintable      super        name: "post-find" -      # We show a "?" badge, but only while an Escape activates insert mode. -      badge: "?"        # PostFindMode shares a singleton with the modes launched by focusInput; each displaces the other.        singleton: element        exitOnBlur: element @@ -54,14 +52,7 @@ class PostFindMode extends SuppressPrintable            @suppressEvent          else            handlerStack.remove() -          @badge = "" -          Mode.updateBadge()            @continueBubbling -  updateBadge: (badge) -> -    badge.badge ||= @badge -    # Suppress the "I" badge from insert mode. -    InsertMode.suppressEvent badge # Always truthy. -  root = exports ? window  root.PostFindMode = PostFindMode diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee index 90162d5a..4e03cdd5 100644 --- a/content_scripts/mode_insert.coffee +++ b/content_scripts/mode_insert.coffee @@ -19,11 +19,15 @@ class InsertMode extends Mode          # the right thing to do for most common use cases.  However, it could also cripple flash-based sites and          # games.  See discussion in #1211 and #1194.          target.blur() +      else if target?.shadowRoot and @insertModeLock +        # An editable element in a shadow DOM is focused; blur it. +        @insertModeLock.blur()        @exit event, event.srcElement        @suppressEvent      defaults =        name: "insert" +      indicator: if @permanent then null else "Insert mode"        keypress: handleKeyEvent        keyup: handleKeyEvent        keydown: handleKeyEvent @@ -52,6 +56,23 @@ class InsertMode extends Mode        "focus": (event) => @alwaysContinueBubbling =>          if @insertModeLock != event.target and DomUtils.isFocusable event.target            @activateOnElement event.target +        else if event.target.shadowRoot +          # A focusable element inside the shadow DOM might have been selected. If so, we can catch the focus +          # event inside the shadow DOM. This fixes #853. +          shadowRoot = event.target.shadowRoot +          eventListeners = {} +          for type in [ "focus", "blur" ] +            eventListeners[type] = do (type) -> +              (event) -> handlerStack.bubbleEvent type, event +            shadowRoot.addEventListener type, eventListeners[type], true + +          handlerStack.push +            _name: "shadow-DOM-input-mode" +            blur: (event) -> +              if event.target.shadowRoot == shadowRoot +                handlerStack.remove() +                for type, listener of eventListeners +                  shadowRoot.removeEventListener type, listener, true      # Only for tests.  This gives us a hook to test the status of the permanently-installed instance.      InsertMode.permanentInstance = @ if @permanent @@ -68,18 +89,13 @@ class InsertMode extends Mode    activateOnElement: (element) ->      @log "#{@id}: activating (permanent)" if @debug and @permanent      @insertModeLock = element -    Mode.updateBadge()    exit: (_, target)  ->      if (target and target == @insertModeLock) or @global or target == undefined        @log "#{@id}: deactivating (permanent)" if @debug and @permanent and @insertModeLock        @insertModeLock = null        # Exit, but only if this isn't the permanently-installed instance. -      if @permanent then Mode.updateBadge() else super() - -  updateBadge: (badge) -> -    badge.badge ||= @badge if @badge -    badge.badge ||= "I" if @isActive badge +      super() unless @permanent    # Static stuff. This allows PostFindMode to suppress the permanently-installed InsertMode instance.    @suppressedEvent: null diff --git a/content_scripts/mode_passkeys.coffee b/content_scripts/mode_passkeys.coffee index 64db5447..1ed69ac2 100644 --- a/content_scripts/mode_passkeys.coffee +++ b/content_scripts/mode_passkeys.coffee @@ -4,21 +4,18 @@ class PassKeysMode extends Mode      super        name: "passkeys"        trackState: true # Maintain @enabled, @passKeys and @keyQueue. -      keydown: (event) => @handleKeyChar KeyboardUtils.getKeyChar event -      keypress: (event) => @handleKeyChar String.fromCharCode event.charCode -      keyup: (event) => @handleKeyChar KeyboardUtils.getKeyChar event +      keydown: (event) => @handleKeyChar event, KeyboardUtils.getKeyChar event +      keypress: (event) => @handleKeyChar event, String.fromCharCode event.charCode +      keyup: (event) => @handleKeyChar event, KeyboardUtils.getKeyChar event    # Keystrokes are *never* considered passKeys if the keyQueue is not empty.  So, for example, if 't' is a    # passKey, then 'gt' and '99t' will neverthless be handled by Vimium. -  handleKeyChar: (keyChar) -> +  handleKeyChar: (event, keyChar) -> +    return @continueBubbling if event.altKey or event.ctrlKey or event.metaKey      if keyChar and not @keyQueue and 0 <= @passKeys.indexOf keyChar        @stopBubblingAndTrue      else        @continueBubbling -  # Disabled, pending experimentation with how/whether to use badges (smblott, 2015/01/17). -  # updateBadge: (badge) -> -  #   badge.badge ||= "P" if @passKeys and not @keyQueue -  root = exports ? window  root.PassKeysMode = PassKeysMode diff --git a/content_scripts/mode_visual_edit.coffee b/content_scripts/mode_visual_edit.coffee index a5758a64..8d1d96cc 100644 --- a/content_scripts/mode_visual_edit.coffee +++ b/content_scripts/mode_visual_edit.coffee @@ -143,7 +143,12 @@ class Movement extends CountPrefix    # Test whether the character following the focus is a word character (and leave the selection unchanged).    nextCharacterIsWordCharacter: do -> -    regexp = /[A-Za-z0-9_]/; -> regexp.test @getNextForwardCharacter() +    regexp = null +    -> +      # This regexp matches "word" characters (apparently in any language). +      # From http://stackoverflow.com/questions/150033/regular-expression-to-match-non-english-characters +      regexp || = /[_0-9\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]/ +      regexp.test @getNextForwardCharacter()    # Run a movement.  This is the core movement method, all movements happen here.  For convenience, the    # following three argument forms are supported: @@ -361,30 +366,30 @@ class Movement extends CountPrefix          @movements.n = (count) -> executeFind count, false          @movements.N = (count) -> executeFind count, true          @movements["/"] = -> -          @findMode = window.enterFindMode() +          @findMode = window.enterFindMode returnToViewport: true            @findMode.onExit => @changeMode VisualMode      #      # End of Movement constructor. -  # Yank the selection; always exits; either deletes the selection or removes it; set @yankedText and return +  # Yank the selection; always exits; either deletes the selection or collapses it; set @yankedText and return    # it.    yank: (args = {}) ->      @yankedText = @selection.toString()      @selection.deleteFromDocument() if @options.deleteFromDocument or args.deleteFromDocument -    @selection.removeAllRanges() unless @options.parentMode +    @selection.collapseToStart() unless @options.parentMode      message = @yankedText.replace /\s+/g, " "      message = message[...12] + "..." if 15 < @yankedText.length      plural = if @yankedText.length == 1 then "" else "s" -    HUD.showForDuration "Yanked #{@yankedText.length} character#{plural}: \"#{message}\".", 2500      @options.onYank?.call @, @yankedText      @exit() +    HUD.showForDuration "Yanked #{@yankedText.length} character#{plural}: \"#{message}\".", 2500      @yankedText    exit: (event, target) ->      unless @options.parentMode or @options.oneMovementOnly -      @selection.removeAllRanges() if event?.type == "keydown" and KeyboardUtils.isEscape event +      @selection.collapseToStart() if event?.type == "keydown" and KeyboardUtils.isEscape event        # Disabled, pending discussion of fine-tuning the UX.  Simpler alternative is implemented above.        # # If we're exiting on escape and there is a range selection, then we leave it in place.  However, an @@ -466,7 +471,7 @@ class VisualMode extends Movement      defaults =        name: "visual" -      badge: "V" +      indicator: if options.indicator? then options.indicator else "Visual mode"        singleton: VisualMode        exitOnEscape: true      super extend defaults, options @@ -489,8 +494,8 @@ class VisualMode extends Movement              @selection.removeAllRanges()          if @selection.type != "Range" -          HUD.showForDuration "No usable selection, entering caret mode...", 2500            @changeMode CaretMode +          HUD.showForDuration "No usable selection, entering caret mode...", 2500            return      @push @@ -567,7 +572,7 @@ class VisualMode extends Movement  class VisualLineMode extends VisualMode    constructor: (options = {}) -> -    super extend { name: "visual/line" }, options +    super extend { name: "visual/line", indicator: "Visual mode (line)" }, options      @extendSelection()      @commands.v = -> @changeMode VisualMode @@ -587,7 +592,7 @@ class CaretMode extends Movement      defaults =        name: "caret" -      badge: "C" +      indicator: "Caret mode"        singleton: VisualMode        exitOnEscape: true      super extend defaults, options @@ -597,8 +602,8 @@ class CaretMode extends Movement        when "None"          @establishInitialSelectionAnchor()          if @selection.type == "None" -          HUD.showForDuration "Create a selection before entering visual mode.", 2500            @exit() +          HUD.showForDuration "Create a selection before entering visual mode.", 2500            return        when "Range"          @collapseSelectionToAnchor() @@ -650,9 +655,9 @@ class EditMode extends Movement      @element = document.activeElement      return unless @element and DomUtils.isEditable @element +    options.indicator = "Edit mode"      defaults =        name: "edit" -      badge: "E"        exitOnEscape: true        exitOnBlur: @element      super extend defaults, options @@ -748,7 +753,6 @@ class EditMode extends Movement    # and (possibly) deletes it.    enterVisualModeForMovement: (count, options = {}) ->      @launchSubMode VisualMode, extend options, -      badge: "M"        initialCountPrefix: count        oneMovementOnly: true diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 5cc3fd82..27fc9cdc 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -123,11 +123,15 @@ CoreScroller =      @lastEvent = null      @keyIsDown = false +    # NOTE(smblott) With extreme keyboard configurations, Chrome sometimes does not get a keyup event for +    # every keydown, in which case tapping "j" scrolls indefinitely.  This appears to be a Chrome/OS/XOrg bug +    # of some kind.  See #1549.      handlerStack.push        _name: 'scroller/track-key-status'        keydown: (event) =>          handlerStack.alwaysContinueBubbling =>            @keyIsDown = true +          @time += 1 unless event.repeat            @lastEvent = event        keyup: =>          handlerStack.alwaysContinueBubbling => @@ -253,22 +257,22 @@ Scroller =      rect = element. getClientRects()?[0]      if rect?        # Scroll y axis. -      if rect.top < 0 -        amount = rect.top - 10 +      if rect.bottom < 0 +        amount = rect.bottom - Math.min(rect.height, window.innerHeight)          element = findScrollableElement element, "y", amount, 1          CoreScroller.scroll element, "y", amount, false -      else if window.innerHeight < rect.bottom -        amount = rect.bottom - window.innerHeight + 10 +      else if window.innerHeight < rect.top +        amount = rect.top + Math.min(rect.height - window.innerHeight, 0)          element = findScrollableElement element, "y", amount, 1          CoreScroller.scroll element, "y", amount, false        # Scroll x axis. -      if rect.left < 0 -        amount = rect.left - 10 +      if rect.right < 0 +        amount = rect.right - Math.min(rect.width, window.innerWidth)          element = findScrollableElement element, "x", amount, 1          CoreScroller.scroll element, "x", amount, false -      else if window.innerWidth < rect.right -        amount = rect.right - window.innerWidth + 10 +      else if window.innerWidth < rect.left +        amount = rect.left + Math.min(rect.width - window.innerWidth, 0)          element = findScrollableElement element, "x", amount, 1          CoreScroller.scroll element, "x", amount, false diff --git a/content_scripts/ui_component.coffee b/content_scripts/ui_component.coffee index c4ed3bf6..e4cfc293 100644 --- a/content_scripts/ui_component.coffee +++ b/content_scripts/ui_component.coffee @@ -2,53 +2,127 @@ class UIComponent    iframeElement: null    iframePort: null    showing: null +  options: null +  shadowDOM: null    constructor: (iframeUrl, className, @handleMessage) -> +    styleSheet = document.createElement "style" + +    unless styleSheet.style +      # If this is an XML document, nothing we do here works: +      # * <style> elements show their contents inline, +      # * <iframe> elements don't load any content, +      # * document.createElement generates elements that have style == null and ignore CSS. +      # If this is the case we don't want to pollute the DOM to no or negative effect.  So we bail +      # immediately, and disable all externally-called methods. +      @postMessage = @activate = @show = @hide = -> +        console.log "This vimium feature is disabled because it is incompatible with this page." +      return + +    styleSheet.type = "text/css" +    # Default to everything hidden while the stylesheet loads. +    styleSheet.innerHTML = "@import url(\"#{chrome.runtime.getURL("content_scripts/vimium.css")}\");" +      @iframeElement = document.createElement "iframe" -    @iframeElement.className = className -    @iframeElement.seamless = "seamless" -    @iframeElement.src = chrome.runtime.getURL iframeUrl -    @iframeElement.addEventListener "load", => @openPort() -    document.documentElement.appendChild @iframeElement +    extend @iframeElement, +      className: className +      seamless: "seamless" +    shadowWrapper = document.createElement "div" +    # PhantomJS doesn't support createShadowRoot, so guard against its non-existance. +    @shadowDOM = shadowWrapper.createShadowRoot?() ? shadowWrapper +    @shadowDOM.appendChild styleSheet +    @shadowDOM.appendChild @iframeElement +      @showing = true # The iframe is visible now.      # Hide the iframe, but don't interfere with the focus.      @hide false -  # Open a port and pass it to the iframe via window.postMessage. -  openPort: -> -    messageChannel = new MessageChannel() -    @iframePort = messageChannel.port1 -    @iframePort.onmessage = (event) => @handleMessage event - -    # Get vimiumSecret so the iframe can determine that our message isn't the page impersonating us. -    chrome.storage.local.get "vimiumSecret", ({vimiumSecret: secret}) => -      @iframeElement.contentWindow.postMessage secret, chrome.runtime.getURL(""), [messageChannel.port2] - -  postMessage: (message) -> -    @iframePort.postMessage message - -  activate: (message) -> -    @postMessage message if message? -    if @showing -      # NOTE(smblott) Experimental.  Not sure this is a great idea. If the iframe was already showing, then -      # the user gets no visual feedback when it is re-focused.  So flash its border. -      @iframeElement.classList.add "vimiumUIComponentReactivated" -      setTimeout((=> @iframeElement.classList.remove "vimiumUIComponentReactivated"), 200) -    else -      @show() -    @iframeElement.focus() +    # Open a port and pass it to the iframe via window.postMessage.  We use an AsyncDataFetcher to handle +    # requests which arrive before the iframe (and its message handlers) have completed initialization.  See +    # #1679. +    @iframePort = new AsyncDataFetcher (setIframePort) => +      # We set the iframe source and append the new element here (as opposed to above) to avoid a potential +      # race condition vis-a-vis the "load" event (because this callback runs on "nextTick"). +      @iframeElement.src = chrome.runtime.getURL iframeUrl +      document.documentElement.appendChild shadowWrapper + +      @iframeElement.addEventListener "load", => +        # Get vimiumSecret so the iframe can determine that our message isn't the page impersonating us. +        chrome.storage.local.get "vimiumSecret", ({ vimiumSecret }) => +          { port1, port2 } = new MessageChannel +          port1.onmessage = (event) => @handleMessage event +          @iframeElement.contentWindow.postMessage vimiumSecret, chrome.runtime.getURL(""), [ port2 ] +          setIframePort port1 + +    # If any other frame in the current tab receives the focus, then we hide the UI component. +    # NOTE(smblott) This is correct for the vomnibar, but might be incorrect (and need to be revisited) for +    # other UI components. +    chrome.runtime.onMessage.addListener (request) => +      @postMessage "hide" if @showing and request.name == "frameFocused" and request.focusFrameId != frameId +      false # Free up the sendResponse handler. + +  # Posts a message (if one is provided), then calls continuation (if provided).  The continuation is only +  # ever called *after* the message has been posted. +  postMessage: (message = null, continuation = null) -> +    @iframePort.use (port) => +      port.postMessage message if message? +      continuation?() + +  activate: (@options) -> +    @postMessage @options, => +      @show() unless @showing +      @iframeElement.focus()    show: (message) -> -    @postMessage message if message? -    @iframeElement.classList.remove "vimiumUIComponentHidden" -    @iframeElement.classList.add "vimiumUIComponentShowing" -    @showing = true +    @postMessage message, => +      @iframeElement.classList.remove "vimiumUIComponentHidden" +      @iframeElement.classList.add "vimiumUIComponentVisible" +      # The window may not have the focus.  We focus it now, to prevent the "focus" listener below from firing +      # immediately. +      window.focus() +      window.addEventListener "focus", @onFocus = (event) => +        if event.target == window +          window.removeEventListener "focus", @onFocus +          @onFocus = null +          @postMessage "hide" +      @showing = true    hide: (focusWindow = true)-> -    @iframeElement.classList.remove "vimiumUIComponentShowing" +    @refocusSourceFrame @options?.sourceFrameId if focusWindow +    window.removeEventListener "focus", @onFocus if @onFocus +    @onFocus = null +    @iframeElement.classList.remove "vimiumUIComponentVisible"      @iframeElement.classList.add "vimiumUIComponentHidden" -    window.focus() if focusWindow +    @options = null      @showing = false +  # Refocus the frame from which the UI component was opened.  This may be different from the current frame. +  # After hiding the UI component, Chrome refocuses the containing frame. To avoid a race condition, we need +  # to wait until that frame first receives the focus, before then focusing the frame which should now have +  # the focus. +  refocusSourceFrame: (sourceFrameId) -> +    if @showing and sourceFrameId? and sourceFrameId != frameId +      refocusSourceFrame = -> +        chrome.runtime.sendMessage +          handler: "sendMessageToFrames" +          message: +            name: "focusFrame" +            frameId: sourceFrameId +            highlight: false +            # Note(smblott) Disabled prior to 1.50 (or post 1.49) release. +            # The UX around flashing the frame isn't quite right yet.  We want the frame to flash only if the +            # user exits the Vomnibar with Escape. +            highlightOnlyIfNotTop: false # true + +      if windowIsFocused() +        # We already have the focus. +        refocusSourceFrame() +      else +        # We don't yet have the focus (but we'll be getting it soon). +        window.addEventListener "focus", handler = (event) -> +          if event.target == window +            window.removeEventListener "focus", handler +            refocusSourceFrame() +  root = exports ? window  root.UIComponent = UIComponent diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css index fb8824c2..b4bce776 100644 --- a/content_scripts/vimium.css +++ b/content_scripts/vimium.css @@ -109,6 +109,20 @@ div.internalVimiumSelectedInputHint span {    color: white !important;  } +/* Frame Highlight Marker CSS*/ +div.vimiumHighlightedFrame { +  position: fixed; +  top: 0px; +  left: 0px; +  width: 100%; +  height: 100%; +  padding: 0px; +  margin: 0px; +  border: 5px solid yellow; +  box-sizing: border-box; +  pointer-events: none; +} +  /* Help Dialog CSS */  div#vimiumHelpDialog { @@ -194,13 +208,18 @@ div#vimiumHelpDialog a {    text-decoration: underline;  } -div#vimiumHelpDialog .optionsPage { +div#vimiumHelpDialog .wikiPage, div#vimiumHelpDialog .optionsPage {    position: absolute;    display: block;    font-size: 11px;    line-height: 130%; -  right: 60px; -  top: 8px; +  top: 6px; +} +div#vimiumHelpDialog .optionsPage { +  right: 40px; +} +div#vimiumHelpDialog .wikiPage { +  right: 83px;  }  div#vimiumHelpDialog a.closeButton:hover {    color:black; @@ -223,6 +242,8 @@ div.vimiumHUD {    display: block;    position: fixed;    bottom: 0px; +  /* Keep this far enough to the right so that it doesn't collide with the "popups blocked" chrome HUD. */ +  right: 0px;    color: black;    height: auto;    min-height: 13px; @@ -237,35 +258,26 @@ div.vimiumHUD {    border-radius: 4px 4px 0 0;    font-family: "Lucida Grande", "Arial", "Sans";    font-size: 12px; -  /* One less than vimium's hint markers, so link hints can be shown e.g. for the HUD panel's close button. */ -  z-index: 2147483646;    text-shadow: 0px 1px 2px #FFF;    line-height: 1.0; -  opacity: 0; -} -/* Hide the span between search box letters */ -div.vimiumHUD span { -  display: none; -} -div.vimiumHUD a:link, div.vimiumHUD a:hover { -  background: transparent; -  color: blue; -  text-decoration: underline;  } -div.vimiumHUD a:link.close-button { -  float:right; -  font-family:courier new; -  font-weight:bold; -  color:#9C9A9A; -  text-decoration:none; -  padding-left:10px; -  margin-top:-1px; -  font-size:14px; -} -div.vimiumHUD a.close-button:hover { -  color:#333333; -  cursor:default; -  -webkit-user-select:none; + +iframe.vimiumHUDFrame { +  display: block; +  background: none; +  position: fixed; +  bottom: 0px; +  right: 150px; +  height: 20px; +  min-height: 20px; +  width: 450px; +  min-width: 150px; +  padding: 0px; +  margin: 0; +  border: none; +  /* One less than vimium's hint markers, so link hints can be shown e.g. for the HUD panel's close button. */ +  z-index: 2147483646; +  opacity: 0;  }  body.vimiumFindMode ::selection { diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 5bad1148..c8c83029 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -12,13 +12,20 @@ findModeInitialRange = null  isShowingHelpDialog = false  keyPort = null  isEnabledForUrl = true -isIncognitoMode = false +isIncognitoMode = chrome.extension.inIncognitoContext  passKeys = null  keyQueue = null  # The user's operating system.  currentCompletionKeys = ""  validFirstKeys = "" +# We track whther the current window has the focus or not. +windowIsFocused = do -> +  windowHasFocus = document.hasFocus() +  window.addEventListener "focus", (event) -> windowHasFocus = true if event.target == window; true +  window.addEventListener "blur", (event) -> windowHasFocus = false if event.target == window; true +  -> windowHasFocus +  # 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. @@ -26,7 +33,7 @@ validFirstKeys = ""  # The corresponding XPath for such elements.  textInputXPath = (-> -  textInputTypes = ["text", "search", "email", "url", "number", "password"] +  textInputTypes = [ "text", "search", "email", "url", "number", "password", "date", "tel" ]    inputElements = ["input[" +      "(" + textInputTypes.map((type) -> '@type="' + type + '"').join(" or ") + "or not(@type))" +      " and not(@disabled or @readonly)]", @@ -39,18 +46,27 @@ textInputXPath = (->  # must be called beforehand to ensure get() will return up-to-date values.  #  settings = -  port: null -  values: {} -  loadedValues: 0 -  valuesToLoad: [ "scrollStepSize", "linkHintCharacters", "linkHintNumbers", "filterLinkHints", "hideHud", -    "previousPatterns", "nextPatterns", "regexFindMode", "userDefinedLinkHintCss", -    "helpDialog_showAdvancedCommands", "smoothScroll", "grabBackFocus" ]    isLoaded: false +  port: null    eventListeners: {} +  values: +    scrollStepSize: null +    linkHintCharacters: null +    linkHintNumbers: null +    filterLinkHints: null +    hideHud: null +    previousPatterns: null +    nextPatterns: null +    regexFindMode: null +    userDefinedLinkHintCss: null +    helpDialog_showAdvancedCommands: null +    smoothScroll: null +    grabBackFocus: null +    searchEngines: null    init: -> -    @port = chrome.runtime.connect({ name: "settings" }) -    @port.onMessage.addListener(@receiveMessage) +    @port = chrome.runtime.connect name: "settings" +    @port.onMessage.addListener (response) => @receiveMessage response      # If the port is closed, the background page has gone away (since we never close it ourselves). Stub the      # settings object so we don't keep trying to connect to the extension even though it's gone away. @@ -60,41 +76,36 @@ settings =          # @get doesn't depend on @port, so we can continue to support it to try and reduce errors.          @[property] = (->) if "function" == typeof value and property != "get" -    get: (key) -> @values[key]    set: (key, value) ->      @init() unless @port      @values[key] = value -    @port.postMessage({ operation: "set", key: key, value: value }) +    @port.postMessage operation: "set", key: key, value: value    load: ->      @init() unless @port +    @port.postMessage operation: "fetch", values: @values -    for i of @valuesToLoad -      @port.postMessage({ operation: "get", key: @valuesToLoad[i] }) - -  receiveMessage: (args) -> -    # not using 'this' due to issues with binding on callback -    settings.values[args.key] = args.value -    # 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 -      listener = null -      while (listener = settings.eventListeners["load"].pop()) -        listener() +  receiveMessage: (response) -> +    @values = response.values if response.values? +    @values[response.key] = response.value if response.key? and response.value? +    @isLoaded = true +    listener() while listener = @eventListeners.load?.pop()    addEventListener: (eventName, callback) -> -    if (!(eventName of @eventListeners)) -      @eventListeners[eventName] = [] -    @eventListeners[eventName].push(callback) +    (@eventListeners[eventName] ||= []).push callback  # -# Give this frame a unique id. +# Give this frame a unique (non-zero) id.  # -frameId = Math.floor(Math.random()*999999999) +frameId = 1 + Math.floor(Math.random()*999999999) + +# For debugging only. This logs to the console on the background page. +bgLog = (args...) -> +  args = (arg.toString() for arg in args) +  chrome.runtime.sendMessage handler: "log", frameId: frameId, message: args.join " "  # If an input grabs the focus before the user has interacted with the page, then grab it back (if the  # grabBackFocus option is set). @@ -123,25 +134,49 @@ class GrabBackFocus extends Mode      element.blur()      @suppressEvent +# Pages can load new content dynamically and change the displayed URL using history.pushState. Since this can +# often be indistinguishable from an actual new page load for the user, we should also re-start GrabBackFocus +# for these as well. This fixes issue #1622. +handlerStack.push +  _name: "GrabBackFocus-pushState-monitor" +  click: (event) -> +    # If a focusable element is focused, the user must have clicked on it. Retain focus and bail. +    return true if DomUtils.isFocusable document.activeElement + +    target = event.target +    while target +      # Often, a link which triggers a content load and url change with javascript will also have the new +      # url as it's href attribute. +      if target.tagName == "A" and +         target.origin == document.location.origin and +         # Clicking the link will change the url of this frame. +         (target.pathName != document.location.pathName or +          target.search != document.location.search) and +         (target.target in ["", "_self"] or +          (target.target == "_parent" and window.parent == window) or +          (target.target == "_top" and window.top == window)) +        return new GrabBackFocus() +      else +        target = target.parentElement +    true +  # Only exported for tests.  window.initializeModes = ->    class NormalMode extends Mode      constructor: ->        super          name: "normal" +        indicator: false # There is no mode indicator in normal mode.          keydown: (event) => onKeydown.call @, event          keypress: (event) => onKeypress.call @, event          keyup: (event) => onKeyup.call @, event -      Scroller.init settings -    # Install the permanent modes.  The permanently-installed insert mode tracks focus/blur events, and    # activates/deactivates itself accordingly. -  new BadgeMode    new NormalMode    new PassKeysMode    new InsertMode permanent: true -  new GrabBackFocus +  Scroller.init settings  #  # Complete initialization work that sould be done prior to DOMReady. @@ -163,31 +198,38 @@ initializePreDomReady = ->      isEnabledForUrl = false      chrome.runtime.sendMessage = ->      chrome.runtime.connect = -> +    window.removeEventListener "focus", onFocus    requestHandlers = -    hideUpgradeNotification: -> HUD.hideUpgradeNotification() -    showUpgradeNotification: (request) -> HUD.showUpgradeNotification(request.version)      showHUDforDuration: (request) -> HUD.showForDuration request.text, request.duration      toggleHelpDialog: (request) -> toggleHelpDialog(request.dialogHtml, request.frameId) -    focusFrame: (request) -> if (frameId == request.frameId) then focusThisFrame(request.highlight) +    focusFrame: (request) -> if (frameId == request.frameId) then focusThisFrame request      refreshCompletionKeys: refreshCompletionKeys      getScrollPosition: -> scrollX: window.scrollX, scrollY: window.scrollY      setScrollPosition: (request) -> setScrollPosition request.scrollX, request.scrollY      executePageCommand: executePageCommand -    getActiveState: getActiveState -    setState: setState      currentKeyQueue: (request) ->        keyQueue = request.keyQueue        handlerStack.bubbleEvent "registerKeyQueue", { keyQueue: keyQueue } +    # A frame has received the focus.  We don't care here (the Vomnibar/UI-component handles this). +    frameFocused: -> +    checkEnabledAfterURLChange: checkEnabledAfterURLChange    chrome.runtime.onMessage.addListener (request, sender, sendResponse) ->      # In the options page, we will receive requests from both content and background scripts. ignore those      # from the former.      return if sender.tab and not sender.tab.url.startsWith 'chrome-extension://' -    return unless isEnabledForUrl or request.name == 'getActiveState' or request.name == 'setState'      # These requests are delivered to the options page, but there are no handlers there. -    return if request.handler == "registerFrame" or request.handler == "frameFocused" -    sendResponse requestHandlers[request.name](request, sender) +    return if request.handler in [ "registerFrame", "frameFocused", "unregisterFrame" ] +    shouldHandleRequest = isEnabledForUrl +    # We always handle the message if it's one of these listed message types. +    shouldHandleRequest ||= request.name in [ "executePageCommand", "checkEnabledAfterURLChange" ] +    # Requests with a frameId of zero should always and only be handled in the main/top frame (regardless of +    # whether Vimium is enabled there). +    if request.frameId == 0 and DomUtils.isTopFrame() +      request.frameId = frameId +      shouldHandleRequest = true +    sendResponse requestHandlers[request.name](request, sender) if shouldHandleRequest      # Ensure the sendResponse callback is freed.      false @@ -201,9 +243,11 @@ installListener = (element, event, callback) ->  # Installing or uninstalling listeners is error prone. Instead we elect to check isEnabledForUrl each time so  # we know whether the listener should run or not.  # Run this as early as possible, so the page can't register any event handlers before us. +# Note: We install the listeners even if Vimium is disabled.  See comment in commit +# 6446cf04c7b44c3d419dc450a73b60bcaf5cdf02.  #  installedListeners = false -window.initializeWhenEnabled = -> +window.installListeners = ->    unless installedListeners      # Key event handlers fire on window before they do on document. Prefer window for key events so the page      # can't set handlers to grab the keys before us. @@ -211,28 +255,26 @@ window.initializeWhenEnabled = ->        do (type) -> installListener window, type, (event) -> handlerStack.bubbleEvent type, event      installListener document, "DOMActivate", (event) -> handlerStack.bubbleEvent 'DOMActivate', event      installedListeners = true - -setState = (request) -> -  isEnabledForUrl = request.enabled -  passKeys = request.passKeys -  isIncognitoMode = request.incognito -  initializeWhenEnabled() if isEnabledForUrl -  FindModeHistory.init() -  handlerStack.bubbleEvent "registerStateChange", -    enabled: isEnabledForUrl -    passKeys: passKeys - -getActiveState = -> -  Mode.updateBadge() -  return { enabled: isEnabledForUrl, passKeys: passKeys } +    # Other once-only initialisation. +    FindModeHistory.init() +    new GrabBackFocus if isEnabledForUrl  # -# The backend needs to know which frame has focus. +# Whenever we get the focus: +# - Reload settings (they may have changed). +# - Tell the background page this frame's URL. +# - Check if we should be enabled.  # -window.addEventListener "focus", -> -  # settings may have changed since the frame last had focus -  settings.load() -  chrome.runtime.sendMessage({ handler: "frameFocused", frameId: frameId }) +onFocus = (event) -> +  if event.target == window +    settings.load() +    chrome.runtime.sendMessage handler: "frameFocused", frameId: frameId +    checkIfEnabledForUrl true + +# We install these listeners directly (that is, we don't use installListener) because we still need to receive +# events when Vimium is not enabled. +window.addEventListener "focus", onFocus +window.addEventListener "hashchange", onFocus  #  # Initialization tasks that must wait for the document to be ready. @@ -241,7 +283,9 @@ initializeOnDomReady = ->    # Tell the background page we're in the dom ready state.    chrome.runtime.connect({ name: "domReady" })    CursorHider.init() -  Vomnibar.init() +  # We only initialize the vomnibar in the tab's main frame, because it's only ever opened there. +  Vomnibar.init() if DomUtils.isTopFrame() +  HUD.init()  registerFrame = ->    # Don't register frameset containers; focusing them is no use. @@ -255,12 +299,23 @@ unregisterFrame = ->    chrome.runtime.sendMessage      handler: "unregisterFrame"      frameId: frameId -    tab_is_closing: window.top == window.self +    tab_is_closing: DomUtils.isTopFrame()  executePageCommand = (request) -> -  return unless frameId == request.frameId +  # Vomnibar commands are handled in the tab's main/top frame.  They are handled even if Vimium is otherwise +  # disabled in the frame. +  if request.command.split(".")[0] == "Vomnibar" +    if DomUtils.isTopFrame() +      # We pass the frameId from request.  That's the frame which originated the request, so that's the frame +      # which should receive the focus when the vomnibar closes. +      Utils.invokeCommandString request.command, [ request.frameId, request.registryEntry ] +      refreshCompletionKeys request +    return -  if (request.passCountToFunction) +  # All other commands are handled in their frame (but only if Vimium is enabled). +  return unless frameId == request.frameId and isEnabledForUrl + +  if request.registryEntry.passCountToFunction      Utils.invokeCommandString(request.command, [request.count])    else      Utils.invokeCommandString(request.command) for i in [0...request.count] @@ -274,18 +329,35 @@ setScrollPosition = (scrollX, scrollY) ->  #  # Called from the backend in order to change frame focus.  # -window.focusThisFrame = (shouldHighlight) -> -  if window.innerWidth < 3 or window.innerHeight < 3 -    # This frame is too small to focus. Cancel and tell the background frame to focus the next one instead. -    # This affects sites like Google Inbox, which have many tiny iframes. See #1317. -    # Here we're assuming that there is at least one frame large enough to focus. -    chrome.runtime.sendMessage({ handler: "nextFrame", frameId: frameId }) -    return -  window.focus() -  if (document.body && shouldHighlight) -    borderWas = document.body.style.border -    document.body.style.border = '5px solid yellow' -    setTimeout((-> document.body.style.border = borderWas), 200) +window.focusThisFrame = do -> +  # Create a shadow DOM wrapping the frame so the page's styles don't interfere with ours. +  highlightedFrameElement = document.createElement "div" +  # PhantomJS doesn't support createShadowRoot, so guard against its non-existance. +  _shadowDOM = highlightedFrameElement.createShadowRoot?() ? highlightedFrameElement + +  # Inject stylesheet. +  _styleSheet = document.createElement "style" +  if _styleSheet.style? +    _styleSheet.innerHTML = "@import url(\"#{chrome.runtime.getURL("content_scripts/vimium.css")}\");" +    _shadowDOM.appendChild _styleSheet + +  _frameEl = document.createElement "div" +  _frameEl.className = "vimiumReset vimiumHighlightedFrame" +  _shadowDOM.appendChild _frameEl + +  (request) -> +    if window.innerWidth < 3 or window.innerHeight < 3 +      # This frame is too small to focus. Cancel and tell the background frame to focus the next one instead. +      # This affects sites like Google Inbox, which have many tiny iframes. See #1317. +      # Here we're assuming that there is at least one frame large enough to focus. +      chrome.runtime.sendMessage({ handler: "nextFrame", frameId: frameId }) +      return +    window.focus() +    shouldHighlight = request.highlight +    shouldHighlight ||= request.highlightOnlyIfNotTop and not DomUtils.isTopFrame() +    if shouldHighlight +      document.documentElement.appendChild highlightedFrameElement +      setTimeout (-> highlightedFrameElement.remove()), 200  extend window,    scrollToBottom: -> Scroller.scrollTo "y", "max" @@ -338,7 +410,9 @@ extend window,        HUD.showForDuration("Yanked #{url}", 2000)    enterInsertMode: -> -    new InsertMode global: true +    # If a focusable element receives the focus, then we exit and leave the permanently-installed insert-mode +    # instance to take over. +    new InsertMode global: true, exitOnFocus: true    enterVisualMode: ->      new VisualMode() @@ -365,7 +439,7 @@ extend window,        visibleInputs =          for i in [0...resultSet.snapshotLength] by 1            element = resultSet.snapshotItem i -          rect = DomUtils.getVisibleClientRect element +          rect = DomUtils.getVisibleClientRect element, true            continue if rect == null            { element: element, rect: rect } @@ -397,7 +471,6 @@ extend window,          constructor: ->            super              name: "focus-selector" -            badge: "?"              exitOnClick: true              keydown: (event) =>                if event.keyCode == KeyboardUtils.keyCodes.tab @@ -434,6 +507,7 @@ extend window,              new mode                singleton: document.activeElement                targetElement: document.activeElement +              indicator: false  # Track which keydown events we have handled, so that we can subsequently suppress the corresponding keyup  # event. @@ -554,20 +628,33 @@ onKeyup = (event) ->    DomUtils.suppressPropagation(event)    @stopBubblingAndTrue -checkIfEnabledForUrl = -> +# Checks if Vimium should be enabled or not in this frame.  As a side effect, it also informs the background +# page whether this frame has the focus, allowing the background page to track the active frame's URL. +checkIfEnabledForUrl = (frameIsFocused = windowIsFocused()) ->    url = window.location.toString() - -  chrome.runtime.sendMessage { handler: "isEnabledForUrl", url: url }, (response) -> -    isEnabledForUrl = response.isEnabledForUrl -    passKeys = response.passKeys -    if isEnabledForUrl -      initializeWhenEnabled() -    else if (HUD.isReady()) +  chrome.runtime.sendMessage { handler: "isEnabledForUrl", url: url, frameIsFocused: frameIsFocused }, (response) -> +    { isEnabledForUrl, passKeys } = response +    installListeners() # But only if they have not been installed already. +    if HUD.isReady() and not isEnabledForUrl        # Quickly hide any HUD we might already be showing, e.g. if we entered insert mode on page load.        HUD.hide()      handlerStack.bubbleEvent "registerStateChange",        enabled: isEnabledForUrl        passKeys: passKeys +    # Update the page icon, if necessary. +    if windowIsFocused() +      chrome.runtime.sendMessage +        handler: "setIcon" +        icon: +          if isEnabledForUrl and not passKeys then "enabled" +          else if isEnabledForUrl then "partial" +          else "disabled" +    null + +# When we're informed by the background page that a URL in this tab has changed, we check if we have the +# correct enabled state (but only if this frame has the focus). +checkEnabledAfterURLChange = -> +  checkIfEnabledForUrl() if windowIsFocused()  # Exported to window, but only for DOM tests.  window.refreshCompletionKeys = (response) -> @@ -632,21 +719,16 @@ updateFindModeQuery = ->    # character. here we grep for the relevant escape sequences.    findModeQuery.isRegex = settings.get 'regexFindMode'    hasNoIgnoreCaseFlag = false -  findModeQuery.parsedQuery = findModeQuery.rawQuery.replace /\\./g, (match) -> -    switch (match) -      when "\\r" +  findModeQuery.parsedQuery = findModeQuery.rawQuery.replace /(\\{1,2})([rRI]?)/g, (match, slashes, flag) -> +    return match if flag == "" or slashes.length != 1 +    switch (flag) +      when "r"          findModeQuery.isRegex = true -        return "" -      when "\\R" +      when "R"          findModeQuery.isRegex = false -        return "" -      when "\\I" +      when "I"          hasNoIgnoreCaseFlag = true -        return "" -      when "\\\\" -        return "\\" -      else -        return match +    ""    # default to 'smartcase' mode, unless noIgnoreCase is explicitly specified    findModeQuery.ignoreCase = !hasNoIgnoreCaseFlag && !Utils.hasUpperCase(findModeQuery.parsedQuery) @@ -689,7 +771,6 @@ handleKeyCharForFindMode = (keyChar) ->    updateQueryForFindMode findModeQuery.rawQuery + keyChar  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. @@ -703,8 +784,7 @@ handleEscapeForFindMode = ->  # Return true if character deleted, false otherwise.  handleDeleteForFindMode = ->    if findModeQuery.rawQuery.length == 0 -    exitFindMode() -    performFindInPlace() +    HUD.hide()      false    else      updateQueryForFindMode findModeQuery.rawQuery.substring(0, findModeQuery.rawQuery.length - 1) @@ -714,22 +794,25 @@ handleDeleteForFindMode = ->  # <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'  handleEnterForFindMode = -> -  exitFindMode()    focusFoundLink()    document.body.classList.add("vimiumFindMode")    FindModeHistory.saveQuery findModeQuery.rawQuery  class FindMode extends Mode -  constructor: -> +  constructor: (options = {}) ->      @historyIndex = -1      @partialQuery = "" +    if options.returnToViewport +      @scrollX = window.scrollX +      @scrollY = window.scrollY      super        name: "find" -      badge: "/" +      indicator: false        exitOnEscape: true        exitOnClick: true        keydown: (event) => +        window.scrollTo @scrollX, @scrollY if options.returnToViewport          if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey            @exit() unless handleDeleteForFindMode()            @suppressEvent @@ -752,8 +835,8 @@ class FindMode extends Mode            DomUtils.suppressPropagation(event)            handlerStack.stopBubblingAndFalse -      keypress: (event) -> -        handlerStack.neverContinueBubbling -> +      keypress: (event) => +        handlerStack.neverContinueBubbling =>            if event.keyCode > 31              keyChar = String.fromCharCode event.charCode              handleKeyCharForFindMode keyChar if keyChar @@ -781,8 +864,6 @@ executeFind = (query, options) ->    document.body.classList.add("vimiumFindMode") -  # prevent find from matching its own search query in the HUD -  HUD.hide(true)    # ignore the selectionchange event generated by find()    document.removeEventListener("selectionchange",restoreDefaultSelectionHighlight, true)    result = window.find(query, options.caseSensitive, options.backwards, true, false, true, false) @@ -794,8 +875,7 @@ executeFind = (query, options) ->    # previous find landed in an editable element, then that element may still be activated.  In this case, we    # don't want to leave it behind (see #1412).    if document.activeElement and DomUtils.isEditable document.activeElement -    if not DomUtils.isSelected document.activeElement -      document.activeElement.blur() +    document.activeElement.blur() unless DomUtils.isSelected document.activeElement    # we need to save the anchor node here because <esc> seems to nullify it, regardless of whether we do    # preventDefault() @@ -987,15 +1067,13 @@ findModeRestoreSelection = (range = findModeInitialRange) ->    selection.addRange range  # Enters find mode.  Returns the new find-mode instance. -window.enterFindMode = -> +window.enterFindMode = (options = {}) ->    # Save the selection, so performFindInPlace can restore it.    findModeSaveSelection() -  findModeQuery = { rawQuery: "" } -  HUD.show("/") -  new FindMode() - -exitFindMode = -> -  HUD.hide() +  findModeQuery = rawQuery: "" +  findMode = new FindMode options +  HUD.show "/" +  findMode  window.showHelpDialog = (html, fid) ->    return if (isShowingHelpDialog || !document.body || fid != frameId) @@ -1043,6 +1121,8 @@ window.showHelpDialog = (html, fid) ->        chrome.runtime.sendMessage({handler: "openOptionsPageInNewTab"})      false) +  # Simulating a click on the help dialog makes it the active element for scrolling. +  DomUtils.simulateClick document.getElementById "vimiumHelpDialog"  hideHelpDialog = (clickEvent) ->    isShowingHelpDialog = false @@ -1058,113 +1138,6 @@ toggleHelpDialog = (html, fid) ->    else      showHelpDialog(html, fid) -# -# A heads-up-display (HUD) for showing Vimium page operations. -# Note: you cannot interact with the HUD until document.body is available. -# -HUD = -  _tweenId: -1 -  _displayElement: null -  _upgradeNotificationElement: null - -  # This HUD is styled to precisely mimick the chrome HUD on Mac. Use the "has_popup_and_link_hud.html" -  # test harness to tweak these styles to match Chrome's. One limitation of our HUD display is that -  # it doesn't sit on top of horizontal scrollbars like Chrome's HUD does. - -  showForDuration: (text, duration) -> -    HUD.show(text) -    HUD._showForDurationTimerId = setTimeout((-> HUD.hide()), duration) - -  show: (text) -> -    return unless HUD.enabled() -    clearTimeout(HUD._showForDurationTimerId) -    HUD.displayElement().innerText = text -    clearInterval(HUD._tweenId) -    HUD._tweenId = Tween.fade(HUD.displayElement(), 1.0, 150) -    HUD.displayElement().style.display = "" - -  showUpgradeNotification: (version) -> -    HUD.upgradeNotificationElement().innerHTML = "Vimium has been upgraded to #{version}. See -      <a class='vimiumReset' target='_blank' -      href='https://github.com/philc/vimium#release-notes'> -      what's new</a>.<a class='vimiumReset close-button' href='#'>×</a>" -    links = HUD.upgradeNotificationElement().getElementsByTagName("a") -    links[0].addEventListener("click", HUD.onUpdateLinkClicked, false) -    links[1].addEventListener "click", (event) -> -      event.preventDefault() -      HUD.onUpdateLinkClicked() -    Tween.fade(HUD.upgradeNotificationElement(), 1.0, 150) - -  onUpdateLinkClicked: (event) -> -    HUD.hideUpgradeNotification() -    chrome.runtime.sendMessage({ handler: "upgradeNotificationClosed" }) - -  hideUpgradeNotification: (clickEvent) -> -    Tween.fade(HUD.upgradeNotificationElement(), 0, 150, -      -> HUD.upgradeNotificationElement().style.display = "none") - -  # -  # Retrieves the HUD HTML element. -  # -  displayElement: -> -    if (!HUD._displayElement) -      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._displayElement - -  upgradeNotificationElement: -> -    if (!HUD._upgradeNotificationElement) -      HUD._upgradeNotificationElement = HUD.createHudElement() -      # Position this just to the left of our normal HUD. -      HUD._upgradeNotificationElement.style.right = "315px" -    HUD._upgradeNotificationElement - -  createHudElement: -> -    element = document.createElement("div") -    element.className = "vimiumReset vimiumHUD" -    document.body.appendChild(element) -    element - -  hide: (immediate) -> -    clearInterval(HUD._tweenId) -    if (immediate) -      HUD.displayElement().style.display = "none" -    else -      HUD._tweenId = Tween.fade(HUD.displayElement(), 0, 150, -        -> HUD.displayElement().style.display = "none") - -  isReady: -> document.body != null - -  # A preference which can be toggled in the Options page. */ -  enabled: -> !settings.get("hideHud") - -Tween = -  # -  # Fades an element's alpha. Returns a timer ID which can be used to stop the tween via clearInterval. -  # -  fade: (element, toAlpha, duration, onComplete) -> -    state = {} -    state.duration = duration -    state.startTime = (new Date()).getTime() -    state.from = parseInt(element.style.opacity) || 0 -    state.to = toAlpha -    state.onUpdate = (value) -> -      element.style.opacity = value -      if (value == state.to && onComplete) -        onComplete() -    state.timerId = setInterval((-> Tween.performTweenStep(state)), 50) -    state.timerId - -  performTweenStep: (state) -> -    elapsed = (new Date()).getTime() - state.startTime -    if (elapsed >= state.duration) -      clearInterval(state.timerId) -      state.onUpdate(state.to) -    else -      value = (elapsed / state.duration)  * (state.to - state.from) + state.from -      state.onUpdate(value) -  CursorHider =    #    # Hide the cursor when the browser scrolls, and prevent mouse from hovering while invisible. @@ -1212,6 +1185,7 @@ window.onbeforeunload = ->  root = exports ? window  root.settings = settings -root.HUD = HUD  root.handlerStack = handlerStack  root.frameId = frameId +root.windowIsFocused = windowIsFocused +root.bgLog = bgLog diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee index 6381fd7f..4bd8e8fd 100644 --- a/content_scripts/vomnibar.coffee +++ b/content_scripts/vomnibar.coffee @@ -4,31 +4,55 @@  Vomnibar =    vomnibarUI: null -  activate: -> @open {completer:"omni"} -  activateInNewTab: -> @open { -    completer: "omni" -    selectFirst: false -    newTab: true -  } -  activateTabSelection: -> @open { +  # Parse any additional options from the command's registry entry.  Currently, this only includes a flag of +  # the form "keyword=X", for direct activation of a custom search engine. +  parseRegistryEntry: (registryEntry = { options: [] }, callback = null) -> +    options = {} +    searchEngines = settings.get("searchEngines") ? "" +    SearchEngines.refreshAndUse searchEngines, (engines) -> +      for option in registryEntry.options +        [ key, value ] = option.split "=" +        switch key +          when "keyword" +            if value? and engines[value]? +              options.keyword = value +            else +              console.log "Vimium configuration error: no such custom search engine: #{option}." +          else +              console.log "Vimium configuration error: unused flag: #{option}." + +      callback? options + +  # sourceFrameId here (and below) is the ID of the frame from which this request originates, which may be different +  # from the current frame. + +  activate: (sourceFrameId, registryEntry) -> +    @parseRegistryEntry registryEntry, (options) => +      @open sourceFrameId, extend options, completer:"omni" + +  activateInNewTab: (sourceFrameId, registryEntry) -> +    @parseRegistryEntry registryEntry, (options) => +      @open sourceFrameId, extend options, completer:"omni", newTab: true + +  activateTabSelection: (sourceFrameId) -> @open sourceFrameId, {      completer: "tabs"      selectFirst: true    } -  activateBookmarks: -> @open { +  activateBookmarks: (sourceFrameId) -> @open sourceFrameId, {      completer: "bookmarks"      selectFirst: true    } -  activateBookmarksInNewTab: -> @open { +  activateBookmarksInNewTab: (sourceFrameId) -> @open sourceFrameId, {      completer: "bookmarks"      selectFirst: true      newTab: true    } -  activateEditUrl: -> @open { +  activateEditUrl: (sourceFrameId) -> @open sourceFrameId, {      completer: "omni"      selectFirst: false      query: window.location.href    } -  activateEditUrlInNewTab: -> @open { +  activateEditUrlInNewTab: (sourceFrameId) -> @open sourceFrameId, {      completer: "omni"      selectFirst: false      query: window.location.href @@ -39,13 +63,18 @@ Vomnibar =      unless @vomnibarUI?        @vomnibarUI = new UIComponent "pages/vomnibar.html", "vomnibarFrame", (event) =>          @vomnibarUI.hide() if event.data == "hide" +      # Whenever the window receives the focus, we tell the Vomnibar UI that it has been hidden (regardless of +      # whether it was previously visible). +      window.addEventListener "focus", (event) => +        @vomnibarUI.postMessage "hidden" if event.target == window; true +    # This function opens the vomnibar. It accepts options, a map with the values:    #   completer   - The completer to fetch results from.    #   query       - Optional. Text to prefill the Vomnibar with.    #   selectFirst - Optional, boolean. Whether to select the first entry.    #   newTab      - Optional, boolean. Whether to open the result in a new tab. -  open: (options) -> @vomnibarUI.activate options +  open: (sourceFrameId, options) -> @vomnibarUI.activate extend options, { sourceFrameId }  root = exports ? window  root.Vomnibar = Vomnibar diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index 2ae9412e..7c47179c 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -27,6 +27,12 @@ DomUtils =    removeElement: (el) -> el.parentNode.removeChild el    # +  # Test whether the current frame is the top/main frame. +  # +  isTopFrame: -> +    window.top == window.self + +  #    # 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. @@ -49,21 +55,37 @@ DomUtils =    #    # Returns the first visible clientRect of an element if it exists. Otherwise it returns null.    # -  getVisibleClientRect: (element) -> +  # WARNING: If testChildren = true then the rects of visible (eg. floated) children may be returned instead. +  # This is used for LinkHints and focusInput, **BUT IS UNSUITABLE FOR MOST OTHER PURPOSES**. +  # +  getVisibleClientRect: (element, testChildren = false) ->      # Note: this call will be expensive if we modify the DOM in between calls.      clientRects = (Rect.copy clientRect for clientRect in element.getClientRects()) +    # Inline elements with font-size: 0px; will declare a height of zero, even if a child with non-zero +    # font-size contains text. +    isInlineZeroHeight = -> +      elementComputedStyle = window.getComputedStyle element, null +      isInlineZeroFontSize = (0 == elementComputedStyle.getPropertyValue("display").indexOf "inline") and +        (elementComputedStyle.getPropertyValue("font-size") == "0px") +      # Override the function to return this value for the rest of this context. +      isInlineZeroHeight = -> isInlineZeroFontSize +      isInlineZeroFontSize +      for clientRect in clientRects -      # If the link has zero dimensions, it may be wrapping visible -      # but floated elements. Check for this. -      if (clientRect.width == 0 || clientRect.height == 0) +      # If the link has zero dimensions, it may be wrapping visible but floated elements. Check for this. +      if (clientRect.width == 0 or clientRect.height == 0) and testChildren          for child in element.children            computedStyle = window.getComputedStyle(child, null) -          # Ignore child elements which are not floated and not absolutely positioned for parent elements with -          # zero width/height -          continue if (computedStyle.getPropertyValue('float') == 'none' && -            computedStyle.getPropertyValue('position') != 'absolute') -          childClientRect = @getVisibleClientRect(child) +          # Ignore child elements which are not floated and not absolutely positioned for parent elements +          # with zero width/height, as long as the case described at isInlineZeroHeight does not apply. +          # NOTE(mrmr1993): This ignores floated/absolutely positioned descendants nested within inline +          # children. +          continue if (computedStyle.getPropertyValue("float") == "none" and +            computedStyle.getPropertyValue("position") != "absolute" and +            not (clientRect.height == 0 and isInlineZeroHeight() and +              0 == computedStyle.getPropertyValue("display").indexOf "inline")) +          childClientRect = @getVisibleClientRect child, true            continue if childClientRect == null or childClientRect.width < 3 or childClientRect.height < 3            return childClientRect @@ -74,9 +96,7 @@ DomUtils =          # eliminate invisible elements (see test_harnesses/visibility_test.html)          computedStyle = window.getComputedStyle(element, null) -        if (computedStyle.getPropertyValue('visibility') != 'visible' || -            computedStyle.getPropertyValue('display') == 'none') -          continue +        continue if computedStyle.getPropertyValue('visibility') != 'visible'          return clientRect @@ -167,15 +187,20 @@ DomUtils =        node = node.parentNode      false -  # True if element contains the active selection range. +  # True if element is editable and contains the active selection range.    isSelected: (element) -> +    selection = document.getSelection()      if element.isContentEditable -      node = document.getSelection()?.anchorNode +      node = selection.anchorNode        node and @isDOMDescendant element, node      else -      # Note.  This makes the wrong decision if the user has placed the caret at the start of element.  We -      # cannot distinguish that case from the user having made no selection. -      element.selectionStart? and element.selectionEnd? and element.selectionEnd != 0 +      if selection.type == "Range" and selection.isCollapsed +	      # The selection is inside the Shadow DOM of a node. We can check the node it registers as being +	      # before, since this represents the node whose Shadow DOM it's inside. +        containerNode = selection.anchorNode.childNodes[selection.anchorOffset] +        element == containerNode # True if the selection is inside the Shadow DOM of our element. +      else +        false    simulateSelect: (element) ->      # If element is already active, then we don't move the selection.  However, we also won't get a new focus @@ -185,11 +210,17 @@ DomUtils =        handlerStack.bubbleEvent "click", target: element      else        element.focus() -      unless @isSelected element -        # When focusing a textbox (without an existing selection), put the selection caret at the end of the -        # textbox's contents.  For some HTML5 input types (eg. date) we can't position the caret, so we wrap -        # this with a try. -        try element.setSelectionRange(element.value.length, element.value.length) +      # If the cursor is at the start of the element's contents, send it to the end. Motivation: +      # * the end is a more useful place to focus than the start, +      # * this way preserves the last used position (except when it's at the beginning), so the user can +      #   'resume where they left off'. +      # NOTE(mrmr1993): Some elements throw an error when we try to access their selection properties, so +      # wrap this with a try. +      try +        if element.selectionStart == 0 and element.selectionEnd == 0 +          element.setSelectionRange element.value.length, element.value.length + +    simulateClick: (element, modifiers) ->      modifiers ||= {} @@ -295,5 +326,24 @@ DomUtils =        document.body.removeChild div        coordinates +  # Get the text content of an element (and its descendents), but omit the text content of previously-visited +  # nodes.  See #1514. +  # NOTE(smblott).  This is currently O(N^2) (when called on N elements).  An alternative would be to mark +  # each node visited, and then clear the marks when we're done. +  textContent: do -> +    visitedNodes = null +    reset: -> visitedNodes = [] +    get: (element) -> +      nodes = document.createTreeWalker element, NodeFilter.SHOW_TEXT +      texts = +        while node = nodes.nextNode() +          continue unless node.nodeType == 3 +          continue if node in visitedNodes +          text = node.data.trim() +          continue unless 0 < text.length +          visitedNodes.push node +          text +      texts.join " " +  root = exports ? window  root.DomUtils = DomUtils diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee index b0fefc7d..b09d3183 100644 --- a/lib/handler_stack.coffee +++ b/lib/handler_stack.coffee @@ -84,10 +84,8 @@ class HandlerStack    # Debugging.    logResult: (eventNumber, type, event, handler, result) -> -    # FIXME(smblott).  Badge updating is too noisy, so we filter it out.  However, we do need to look at how -    # many badge update events are happening.  It seems to be more than necessary. We also filter out -    # registerKeyQueue as unnecessarily noisy and not particularly helpful. -    return if type in [ "updateBadge", "registerKeyQueue" ] +    # Key queue events aren't usually useful for debugging, so we filter them out. +    return if type in [ "registerKeyQueue" ]      label =        switch result          when @stopBubblingAndTrue then "stop/true" diff --git a/lib/settings.coffee b/lib/settings.coffee new file mode 100644 index 00000000..dd667dbd --- /dev/null +++ b/lib/settings.coffee @@ -0,0 +1,202 @@ +# +# * Sync.set() and Sync.clear() propagate local changes to chrome.storage.sync. +# * Sync.handleStorageUpdate() listens for changes to chrome.storage.sync and propagates those +#   changes to localStorage and into vimium's internal state. +# * Sync.fetchAsync() polls chrome.storage.sync at startup, similarly propagating +#   changes to localStorage and into vimium's internal state. +# +# The effect is best-effort synchronization of vimium options/settings between +# chrome/vimium instances. +# +# NOTE: +#   Values handled within this module are ALWAYS already JSON.stringifed, so +#   they're always non-empty strings. +# + +root = exports ? window +Sync = + +  storage: chrome.storage.sync +  doNotSync: ["settingsVersion", "previousVersion"] + +  # This is called in main.coffee. +  init: -> +    chrome.storage.onChanged.addListener (changes, area) -> Sync.handleStorageUpdate changes, area +    @fetchAsync() + +  # Asynchronous fetch from synced storage, called only at startup. +  fetchAsync: -> +    @storage.get null, (items) => +      unless chrome.runtime.lastError +        for own key, value of items +          Settings.storeAndPropagate key, value if @shouldSyncKey key + +  # Asynchronous message from synced storage. +  handleStorageUpdate: (changes, area) -> +    for own key, change of changes +      Settings.storeAndPropagate key, change?.newValue if @shouldSyncKey key + +  # Only called synchronously from within vimium, never on a callback. +  # No need to propagate updates to the rest of vimium, that's already been done. +  set: (key, value) -> +    if @shouldSyncKey key +      setting = {}; setting[key] = value +      @storage.set setting + +  # Only called synchronously from within vimium, never on a callback. +  clear: (key) -> +    @storage.remove key if @shouldSyncKey key + +  # Should we synchronize this key? +  shouldSyncKey: (key) -> key not in @doNotSync + +# +# Used by all parts of Vimium to manipulate localStorage. +# + +# Select the object to use as the cache for settings. +if Utils.isExtensionPage() +  if Utils.isBackgroundPage() +    settingsCache = localStorage +  else +    settingsCache = extend {}, localStorage # Make a copy of the cached settings from localStorage +else +  settingsCache = {} + +root.Settings = Settings = +  cache: settingsCache +  init: -> Sync.init() +  get: (key) -> +    if (key of @cache) then JSON.parse(@cache[key]) else @defaults[key] + +  set: (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 == @defaults[key]) +      @clear(key) +    else +      jsonValue = JSON.stringify value +      @cache[key] = jsonValue +      Sync.set key, jsonValue + +  clear: (key) -> +    if @has key +      delete @cache[key] +    Sync.clear key + +  has: (key) -> key of @cache + +  # For settings which require action when their value changes, add hooks to this object, to be called from +  # options/options.coffee (when the options page is saved), and by Settings.storeAndPropagate (when an +  # update propagates from chrome.storage.sync). +  postUpdateHooks: {} + +  # postUpdateHooks convenience wrapper +  performPostUpdateHook: (key, value) -> +    @postUpdateHooks[key]? value + +  # Only ever called from asynchronous synced-storage callbacks (fetchAsync and handleStorageUpdate). +  storeAndPropagate: (key, value) -> +    return unless key of @defaults +    return if value and key of @cache and @cache[key] is value +    defaultValue = @defaults[key] +    defaultValueJSON = JSON.stringify(defaultValue) + +    if value and value != defaultValueJSON +      # Key/value has been changed to non-default value at remote instance. +      @cache[key] = value +      @performPostUpdateHook key, JSON.parse(value) +    else +      # Key has been reset to default value at remote instance. +      if key of @cache +        delete @cache[key] +      @performPostUpdateHook key, defaultValue + +  # options.coffee and options.html only handle booleans and strings; therefore all defaults must be booleans +  # or strings +  defaults: +    scrollStepSize: 60 +    smoothScroll: true +    keyMappings: "# Insert your preferred key mappings here." +    linkHintCharacters: "sadfjklewcmpgh" +    linkHintNumbers: "0123456789" +    filterLinkHints: false +    hideHud: false +    userDefinedLinkHintCss: +      """ +      div > .vimiumHintMarker { +      /* linkhint boxes */ +      background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785), +        color-stop(100%,#FFC542)); +      border: 1px solid #E3BE23; +      } + +      div > .vimiumHintMarker span { +      /* linkhint text */ +      color: black; +      font-weight: bold; +      font-size: 12px; +      } + +      div > .vimiumHintMarker > .matchingCharacter { +      } +      """ +    # Default exclusion rules. +    exclusionRules: +      [ +        # Disable Vimium on Gmail. +        { pattern: "http*://mail.google.com/*", passKeys: "" } +      ] + +    # 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,>>" +    # default/fall back search engine +    searchUrl: "https://www.google.com/search?q=" +    # put in an example search engine +    searchEngines: [ +      "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s Wikipedia" +      "" +      "# More examples." +      "#" +      "# (Vimium has built-in completion for these.)" +      "#" +      "# g: http://www.google.com/search?q=%s Google" +      "# l: http://www.google.com/search?q=%s&btnI I'm feeling lucky..." +      "# y: http://www.youtube.com/results?search_query=%s Youtube" +      "# b: https://www.bing.com/search?q=%s Bing" +      "# d: https://duckduckgo.com/?q=%s DuckDuckGo" +      "# az: http://www.amazon.com/s/?field-keywords=%s Amazon" +      "#" +      "# Another example (for Vimium does not have completion)." +      "#" +      "# m: https://www.google.com/maps/search/%s Google Maps" +      ].join "\n" +    newTabUrl: "chrome://newtab" +    grabBackFocus: false + +    settingsVersion: Utils.getCurrentVersion() + +# Export Sync via Settings for tests. +root.Settings.Sync = Sync + +# Perform migration from old settings versions, if this is the background page. +if Utils.isBackgroundPage() + +  # We use settingsVersion to coordinate any necessary schema changes. +  if Utils.compareVersions("1.42", Settings.get("settingsVersion")) != -1 +    Settings.set("scrollStepSize", parseFloat Settings.get("scrollStepSize")) +  Settings.set("settingsVersion", Utils.getCurrentVersion()) + +  # Migration (after 1.49, 2015/2/1). +  # Legacy setting: findModeRawQuery (a string). +  # New setting: findModeRawQueryList (a list of strings), now stored in chrome.storage.local (not localStorage). +  chrome.storage.local.get "findModeRawQueryList", (items) -> +    unless chrome.runtime.lastError or items.findModeRawQueryList +      rawQuery = Settings.get "findModeRawQuery" +      chrome.storage.local.set findModeRawQueryList: (if rawQuery then [ rawQuery ] else []) diff --git a/lib/utils.coffee b/lib/utils.coffee index 64c87842..93045f32 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -2,6 +2,13 @@ Utils =    getCurrentVersion: ->      chrome.runtime.getManifest().version +  # Returns true whenever the current page is from the extension's origin (and thus can access the +  # extension's localStorage). +  isExtensionPage: -> document.location?.origin + "/" == chrome.extension.getURL "" + +  # Returns true whenever the current page is the extension's background page. +  isBackgroundPage: -> @isExtensionPage() and chrome.extension.getBackgroundPage() == window +    # Takes a dot-notation object string and call the function    # that it points to with the correct value for 'this'.    invokeCommandString: (str, argArray) -> @@ -26,16 +33,30 @@ Utils =      -> id += 1    hasChromePrefix: do -> -    chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:", "javascript:" ] +    chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:" ]      (url) ->        for prefix in chromePrefixes          return true if url.startsWith prefix        false +  hasJavascriptPrefix: (url) -> +    url.startsWith "javascript:" +    hasFullUrlPrefix: do ->      urlPrefix = new RegExp "^[a-z]{3,}://."      (url) -> urlPrefix.test url +  # Decode valid escape sequences in a URI.  This is intended to mimic the best-effort decoding +  # Chrome itself seems to apply when a Javascript URI is enetered into the omnibox (or clicked). +  # See https://code.google.com/p/chromium/issues/detail?id=483000, #1611 and #1636. +  decodeURIByParts: (uri) -> +    uri.split(/(?=%)/).map((uriComponent) -> +      try +        decodeURIComponent uriComponent +      catch +        uriComponent +    ).join "" +    # Completes a partial URL (without scheme)    createFullUrl: (partialUrl) ->      if @hasFullUrlPrefix(partialUrl) then partialUrl else ("http://" + partialUrl) @@ -93,11 +114,32 @@ Utils =      query = query.split(/\s+/) if typeof(query) == "string"      query.map(encodeURIComponent).join "+" -  # Creates a search URL from the given :query. -  createSearchUrl: (query) -> -    # It would be better to pull the default search engine from chrome itself.  However, unfortunately chrome -    # does not provide an API for doing so. -    Settings.get("searchUrl") + @createSearchQuery query +  # Create a search URL from the given :query (using either the provided search URL, or the default one). +  # It would be better to pull the default search engine from chrome itself.  However, chrome does not provide +  # an API for doing so. +  createSearchUrl: (query, searchUrl = Settings.get("searchUrl")) -> +    searchUrl += "%s" unless 0 <= searchUrl.indexOf "%s" +    searchUrl.replace /%s/g, @createSearchQuery query + +  # Extract a query from url if it appears to be a URL created from the given search URL. +  # For example, map "https://www.google.ie/search?q=star+wars&foo&bar" to "star wars". +  extractQuery: do => +    queryTerminator = new RegExp "[?&#/]" +    httpProtocolRegexp = new RegExp "^https?://" +    (searchUrl, url) -> +      url = url.replace httpProtocolRegexp +      searchUrl = searchUrl.replace httpProtocolRegexp +      [ searchUrl, suffixTerms... ] = searchUrl.split "%s" +      # We require the URL to start with the search URL. +      return null unless url.startsWith searchUrl +      # We require any remaining terms in the search URL to also be present in the URL. +      for suffix in suffixTerms +        return null unless 0 <= url.indexOf suffix +      # We use try/catch because decodeURIComponent can throw an exception. +      try +          url[searchUrl.length..].split(queryTerminator)[0].split("+").map(decodeURIComponent).join " " +      catch +        null    # Converts :string into a Google search if it's not already a URL. We don't bother with escaping characters    # as Chrome will do that for us. @@ -107,6 +149,8 @@ Utils =      # Special-case about:[url], view-source:[url] and the like      if Utils.hasChromePrefix string        string +    else if Utils.hasJavascriptPrefix string +      Utils.decodeURIByParts string      else if Utils.isUrl string        Utils.createFullUrl string      else @@ -169,6 +213,61 @@ Utils =      delete obj[property] for property in properties      obj +  # Does string match any of these regexps? +  matchesAnyRegexp: (regexps, string) -> +    for re in regexps +      return true if re.test string +    false + +  # Calculate the length of the longest shared prefix of a list of strings. +  longestCommonPrefix: (strings) -> +    return 0 unless 0 < strings.length +    strings.sort (a,b) -> a.length - b.length +    [ shortest, strings... ] = strings +    for ch, index in shortest.split "" +      for str in strings +        return index if ch != str[index] +    return shortest.length + +  # Convenience wrapper for setTimeout (with the arguments around the other way). +  setTimeout: (ms, func) -> setTimeout func, ms + +  # Like Nodejs's nextTick. +  nextTick: (func) -> @setTimeout 0, func + +# Utility for parsing and using the custom search-engine configuration.  We re-use the previous parse if the +# search-engine configuration is unchanged. +SearchEngines = +  previousSearchEngines: null +  searchEngines: null + +  refresh: (searchEngines) -> +    unless @previousSearchEngines? and searchEngines == @previousSearchEngines +      @previousSearchEngines = searchEngines +      @searchEngines = new AsyncDataFetcher (callback) -> +        engines = {} +        for line in searchEngines.split "\n" +          line = line.trim() +          continue if /^[#"]/.test line +          tokens = line.split /\s+/ +          continue unless 2 <= tokens.length +          keyword = tokens[0].split(":")[0] +          searchUrl = tokens[1] +          description = tokens[2..].join(" ") || "search (#{keyword})" +          continue unless Utils.hasFullUrlPrefix searchUrl +          engines[keyword] = { keyword, searchUrl, description } + +        callback engines + +  # Use the parsed search-engine configuration, possibly asynchronously. +  use: (callback) -> +    @searchEngines.use callback + +  # Both set (refresh) the search-engine configuration and use it at the same time. +  refreshAndUse: (searchEngines, callback) -> +    @refresh searchEngines +    @use callback +  # This creates a new function out of an existing function, where the new function takes fewer arguments. This  # allows us to pass around functions instead of functions + a partial list of arguments.  Function::curry = -> @@ -179,6 +278,8 @@ Function::curry = ->  Array.copy = (array) -> Array.prototype.slice.call(array, 0)  String::startsWith = (str) -> @indexOf(str) == 0 +String::ltrim = -> @replace /^\s+/, "" +String::rtrim = -> @replace /\s+$/, ""  globalRoot = window ? global  globalRoot.extend = (hash1, hash2) -> @@ -186,5 +287,84 @@ globalRoot.extend = (hash1, hash2) ->      hash1[key] = hash2[key]    hash1 +# A simple cache. Entries used within two expiry periods are retained, otherwise they are discarded. +# At most 2 * @entries entries are retained. +class SimpleCache +  # expiry: expiry time in milliseconds (default, one hour) +  # entries: maximum number of entries in @cache (there may be up to this many entries in @previous, too) +  constructor: (@expiry = 60 * 60 * 1000, @entries = 1000) -> +    @cache = {} +    @previous = {} +    @lastRotation = new Date() + +  has: (key) -> +    @rotate() +    (key of @cache) or key of @previous + +  # Set value, and return that value.  If value is null, then delete key. +  set: (key, value = null) -> +    @rotate() +    delete @previous[key] +    if value? +      @cache[key] = value +    else +      delete @cache[key] +      null + +  get: (key) -> +    @rotate() +    if key of @cache +      @cache[key] +    else if key of @previous +      @cache[key] = @previous[key] +      delete @previous[key] +      @cache[key] +    else +      null + +  rotate: (force = false) -> +    if force or @entries < Object.keys(@cache).length or @expiry < new Date() - @lastRotation +      @lastRotation = new Date() +      @previous = @cache +      @cache = {} + +  clear: -> +    @rotate true +    @rotate true + +# This is a simple class for the common case where we want to use some data value which may be immediately +# available, or for which we may have to wait.  It implements a use-immediately-or-wait queue, and calls the +# fetch function to fetch the data asynchronously. +class AsyncDataFetcher +  constructor: (fetch) -> +    @data = null +    @queue = [] +    Utils.nextTick => +      fetch (@data) => +        callback @data for callback in @queue +        @queue = null + +  use: (callback) -> +    if @data? then callback @data else @queue.push callback + +# This takes a list of jobs (functions) and runs them, asynchronously.  Functions queued with @onReady() are +# run once all of the jobs have completed. +class JobRunner +  constructor: (@jobs) -> +    @fetcher = new AsyncDataFetcher (callback) => +      for job in @jobs +        do (job) => +          Utils.nextTick => +            job => +              @jobs = @jobs.filter (j) -> j != job +              callback true if @jobs.length == 0 + +  onReady: (callback) -> +    @fetcher.use callback +  root = exports ? window  root.Utils = Utils +root.SearchEngines = SearchEngines +root.SimpleCache = SimpleCache +root.AsyncDataFetcher = AsyncDataFetcher +root.JobRunner = JobRunner diff --git a/manifest.json b/manifest.json index beb68530..f0c51117 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@  {    "manifest_version": 2,    "name": "Vimium", -  "version": "1.49", +  "version": "1.51",    "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,11 +9,12 @@    "background": {      "scripts": [        "lib/utils.js", +      "lib/settings.js",        "background_scripts/commands.js",        "lib/clipboard.js", -      "background_scripts/sync.js", -      "background_scripts/settings.js",        "background_scripts/exclusions.js", +      "background_scripts/completion_engines.js", +      "background_scripts/completion_search.js",        "background_scripts/completion.js",        "background_scripts/marks.js",        "background_scripts/main.js" @@ -27,6 +28,8 @@      "clipboardRead",      "storage",      "sessions", +    "notifications", +    "webNavigation",      "<all_urls>"    ],    "content_scripts": [ @@ -48,6 +51,7 @@               "content_scripts/mode_passkeys.js",               "content_scripts/mode_find.js",               "content_scripts/mode_visual_edit.js", +             "content_scripts/hud.js",               "content_scripts/vimium_frontend.js"              ],        "css": ["content_scripts/vimium.css"], @@ -66,6 +70,8 @@      "default_popup": "pages/popup.html"    },    "web_accessible_resources": [ -    "pages/vomnibar.html" +    "pages/vomnibar.html", +    "content_scripts/vimium.css", +    "pages/hud.html"    ]  } diff --git a/pages/help_dialog.html b/pages/help_dialog.html index 0884f2cd..5c09c0ab 100644 --- a/pages/help_dialog.html +++ b/pages/help_dialog.html @@ -7,6 +7,7 @@         page with the up-to-date key bindings when the dialog is shown. -->    <div id="vimiumHelpDialog" class="vimiumReset">    <a class="vimiumReset optionsPage" href="#">Options</a> +  <a class="vimiumReset wikiPage" href="https://github.com/philc/vimium/wiki" target="_blank">Wiki</a>    <a class="vimiumReset closeButton" href="#">×</a>    <div id="vimiumTitle" class="vimiumReset"><span class="vimiumReset" style="color:#2f508e">Vim</span>ium {{title}}</div>    <div class="vimiumReset vimiumColumn"> @@ -20,6 +21,8 @@    <div class="vimiumReset vimiumColumn">      <table class="vimiumReset" >        <tbody class="vimiumReset"> +      <tr class="vimiumReset" ><td class="vimiumReset" ></td><td class="vimiumReset" ></td><td class="vimiumReset vimiumHelpSectionTitle">Using the vomnibar</td></tr> +      {{vomnibarCommands}}        <tr class="vimiumReset" ><td class="vimiumReset" ></td><td class="vimiumReset" ></td><td class="vimiumReset vimiumHelpSectionTitle">Using find</td></tr>        {{findCommands}}        <tr class="vimiumReset" ><td class="vimiumReset" ></td><td class="vimiumReset" ></td><td class="vimiumReset vimiumHelpSectionTitle">Navigating history</td></tr> @@ -46,6 +49,7 @@      </div>      <div class="vimiumReset vimiumColumn" style="text-align:right">        <span class="vimiumReset">Version {{version}}</span><br/> +      <a href="https://github.com/philc/vimium#release-notes" class="vimiumReset">What's new?</a>      </div>    </div>  </div> diff --git a/pages/hud.coffee b/pages/hud.coffee new file mode 100644 index 00000000..68283451 --- /dev/null +++ b/pages/hud.coffee @@ -0,0 +1,15 @@ +handlers = +  show: (data) -> +    document.getElementById("hud").innerText = data.text +    document.getElementById("hud").classList.add "vimiumUIComponentVisible" +    document.getElementById("hud").classList.remove "vimiumUIComponentHidden" +  hide: -> +    # We get a flicker when the HUD later becomes visible again (with new text) unless we reset its contents +    # here. +    document.getElementById("hud").innerText = "" +    document.getElementById("hud").classList.add "vimiumUIComponentHidden" +    document.getElementById("hud").classList.remove "vimiumUIComponentVisible" + +UIComponentServer.registerHandler (event) -> +  {data} = event +  handlers[data.name]? data diff --git a/pages/hud.html b/pages/hud.html new file mode 100644 index 00000000..bcb38e04 --- /dev/null +++ b/pages/hud.html @@ -0,0 +1,11 @@ +<html> +  <head> +    <title>HUD</title> +    <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> +    <script type="text/javascript" src="ui_component_server.js"></script> +    <script type="text/javascript" src="hud.js"></script> +  </head> +  <body> +    <div class="vimiumReset vimiumHUD" id="hud"></div> +  </body> +</html> diff --git a/pages/options.coffee b/pages/options.coffee index 5a4a93ab..c8c21850 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -1,7 +1,6 @@  $ = (id) -> document.getElementById id -bgUtils = chrome.extension.getBackgroundPage().Utils -bgSettings = chrome.extension.getBackgroundPage().Settings +Settings.init()  bgExclusions = chrome.extension.getBackgroundPage().Exclusions  # @@ -22,21 +21,20 @@ class Option    # Fetch a setting from localStorage, remember the @previous value and populate the DOM element.    # Return the fetched value.    fetch: -> -    @populateElement @previous = bgSettings.get @field +    @populateElement @previous = Settings.get @field      @previous    # Write this option's new value back to localStorage, if necessary.    save: ->      value = @readValueFromElement()      if not @areEqual value, @previous -      bgSettings.set @field, @previous = value -      bgSettings.performPostUpdateHook @field, value +      Settings.set @field, @previous = value    # Compare values; this is overridden by sub-classes.    areEqual: (a,b) -> a == b    restoreToDefault: -> -    bgSettings.clear @field +    Settings.clear @field      @fetch()    # Static method. @@ -118,8 +116,8 @@ class ExclusionRulesOption extends Option    readValueFromElement: ->      rules =        for element in @element.getElementsByClassName "exclusionRuleTemplateInstance" -        pattern: @getPattern(element).value.split(/\s+/).join "" -        passKeys: @getPassKeys(element).value.split(/\s+/).join "" +        pattern: @getPattern(element).value.trim() +        passKeys: @getPassKeys(element).value.trim()      rules.filter (rule) -> rule.pattern    areEqual: (a,b) -> @@ -260,6 +258,7 @@ initOptionsPage = ->      searchEngines: TextOption      searchUrl: NonEmptyTextOption      userDefinedLinkHintCss: TextOption +    omniSearchWeight: NumberOption    # Populate options. The constructor adds each new object to "Option.all".    for name, type of options @@ -270,8 +269,12 @@ initPopupPage = ->      exclusions = null      document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html") +    # As the active URL, we choose the most recently registered URL from a frame in the tab, or the tab's own +    # URL. +    url = chrome.extension.getBackgroundPage().urlForTab[tab.id] || tab.url +      updateState = -> -      rule = bgExclusions.getRule tab.url, exclusions.readValueFromElement() +      rule = bgExclusions.getRule url, exclusions.readValueFromElement()        $("state").innerHTML = "Vimium will " +          if rule and rule.passKeys            "exclude <span class='code'>#{rule.passKeys}</span>" @@ -290,8 +293,6 @@ initPopupPage = ->        Option.saveOptions()        $("saveOptions").innerHTML = "Saved"        $("saveOptions").disabled = true -      chrome.tabs.query { windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, (tabs) -> -        chrome.extension.getBackgroundPage().updateActiveState(tabs[0].id)      $("saveOptions").addEventListener "click", saveOptions @@ -301,7 +302,7 @@ initPopupPage = ->          window.close()      # Populate options. Just one, here. -    exclusions = new ExclusionRulesOnPopupOption(tab.url, "exclusionRules", onUpdated) +    exclusions = new ExclusionRulesOnPopupOption url, "exclusionRules", onUpdated      updateState()      document.addEventListener "keyup", updateState diff --git a/pages/options.css b/pages/options.css index 8d1014dc..ffb348c6 100644 --- a/pages/options.css +++ b/pages/options.css @@ -107,9 +107,10 @@ input#linkHintNumbers {  input#linkHintCharacters {    width: 100%;  } -input#scrollStepSize { -  width: 40px; +input#scrollStepSize, input#omniSearchWeight { +  width: 50px;    margin-right: 3px; +  padding-left: 3px;  }  textarea#userDefinedLinkHintCss, textarea#keyMappings, textarea#searchEngines {    width: 100%;; diff --git a/pages/options.html b/pages/options.html index 777d514a..b14c454f 100644 --- a/pages/options.html +++ b/pages/options.html @@ -3,6 +3,7 @@      <title>Vimium Options</title>      <link rel="stylesheet" type="text/css" href="options.css">      <script src="content_script_loader.js"></script> +    <script type="text/javascript" src="../lib/settings.js"></script>      <script type="text/javascript" src="options.js"></script>    </head> @@ -68,6 +69,9 @@ b: http://b.com/?q=%s description          </tr>          <tbody id='advancedOptions'>            <tr> +            <td colspan="2"><header>Advanced Options</header></td> +          </tr> +          <tr>              <td class="caption">Scroll step size</td>              <td>                  <div class="help"> @@ -197,7 +201,7 @@ b: http://b.com/?q=%s description                  <div class="help">                    <div class="example">                        The page to open with the "create new tab" command. -                      Set this to "<tt>pages/blank.html</tt>" for a blank page.<br /> +                      Set this to "<tt>pages/blank.html</tt>" for a blank page (except incognito mode).<br />                    </div>                  </div>                  <input id="newTabUrl" type="text" /> @@ -230,6 +234,36 @@ b: http://b.com/?q=%s description                <div class="nonEmptyTextOption">              </td>            </tr> + +          <!-- Vimium Labs --> +          <!-- +          Disabled.  But we leave this code here as a template for the next time we need to introduce "Vimium Labs". +          <tr> +            <td colspan="2"><header>Vimium Labs</header></td> +          </tr> +          <tr> +            <td class="caption"></td> +            <td> +                <div class="help"> +                  <div class="example"> +                  </div> +                </div> +                These features are experimental and may be changed or removed in future releases. +            </td> +          </tr> +          <tr> +            <td class="caption">Search weighting</td> +            <td> +                <div class="help"> +                  <div class="example"> +                    How prominent should suggestions be in the vomnibar? +                    <tt>0</tt> disables suggestions altogether. +                  </div> +                </div> +              <input id="omniSearchWeight" type="number" min="0.0" max="1.0" step="0.05" />(0 to 1) +            </td> +          </tr> +          -->          </tbody>        </table>      </div> diff --git a/pages/popup.html b/pages/popup.html index c7e2fd6f..fdf116e5 100644 --- a/pages/popup.html +++ b/pages/popup.html @@ -48,6 +48,8 @@        }      </style> +    <script src="../lib/utils.js"></script> +    <script src="../lib/settings.js"></script>      <script src="options.js"></script>    </head>    <body> diff --git a/pages/vomnibar.coffee b/pages/vomnibar.coffee index 18a72a37..d5659fdc 100644 --- a/pages/vomnibar.coffee +++ b/pages/vomnibar.coffee @@ -9,79 +9,97 @@ Vomnibar =    completers: {}    getCompleter: (name) -> -    if (!(name of @completers)) -      @completers[name] = new BackgroundCompleter(name) -    @completers[name] +    @completers[name] ?= new BackgroundCompleter name -  # -  # Activate the Vomnibox. -  #    activate: (userOptions) ->      options =        completer: "omni"        query: ""        newTab: false        selectFirst: false +      keyword: null      extend options, userOptions +    extend options, refreshInterval: if options.completer == "omni" then 150 else 0 -    options.refreshInterval = switch options.completer -      when "omni" then 100 -      else 0 - -    completer = @getCompleter(options.completer) +    completer = @getCompleter options.completer      @vomnibarUI ?= new VomnibarUI() -    completer.refresh() -    @vomnibarUI.setInitialSelectionValue(if options.selectFirst then 0 else -1) -    @vomnibarUI.setCompleter(completer) -    @vomnibarUI.setRefreshInterval(options.refreshInterval) -    @vomnibarUI.setForceNewTab(options.newTab) -    @vomnibarUI.setQuery(options.query) -    @vomnibarUI.update() +    completer.refresh @vomnibarUI +    @vomnibarUI.setInitialSelectionValue if options.selectFirst then 0 else -1 +    @vomnibarUI.setCompleter completer +    @vomnibarUI.setRefreshInterval options.refreshInterval +    @vomnibarUI.setForceNewTab options.newTab +    @vomnibarUI.setQuery options.query +    @vomnibarUI.setKeyword options.keyword +    @vomnibarUI.update true + +  hide: -> @vomnibarUI?.hide() +  onHidden: -> @vomnibarUI?.onHidden()  class VomnibarUI    constructor: ->      @refreshInterval = 0 +    @postHideCallback = null      @initDom()    setQuery: (query) -> @input.value = query - -  setInitialSelectionValue: (initialSelectionValue) -> -    @initialSelectionValue = initialSelectionValue - -  setCompleter: (completer) -> -    @completer = completer -    @reset() -    @update(true) - -  setRefreshInterval: (refreshInterval) -> @refreshInterval = refreshInterval - -  setForceNewTab: (forceNewTab) -> @forceNewTab = forceNewTab - -  hide: -> +  setKeyword: (keyword) -> @customSearchMode = keyword +  setInitialSelectionValue: (@initialSelectionValue) -> +  setRefreshInterval: (@refreshInterval) -> +  setForceNewTab: (@forceNewTab) -> +  setCompleter: (@completer) -> @reset() +  setKeywords: (@keywords) -> + +  # The sequence of events when the vomnibar is hidden is as follows: +  # 1. Post a "hide" message to the host page. +  # 2. The host page hides the vomnibar. +  # 3. When that page receives the focus, and it posts back a "hidden" message. +  # 3. Only once the "hidden" message is received here is any required action  invoked (in onHidden). +  # This ensures that the vomnibar is actually hidden before any new tab is created, and avoids flicker after +  # opening a link in a new tab then returning to the original tab (see #1485). +  hide: (@postHideCallback = null) ->      UIComponentServer.postMessage "hide"      @reset() +    @completer?.reset() + +  onHidden: -> +    @postHideCallback?() +    @postHideCallback = null    reset: -> +    @clearUpdateTimer()      @completionList.style.display = ""      @input.value = "" -    @updateTimer = null      @completions = [] +    @previousInputValue = null +    @customSearchMode = null      @selection = @initialSelectionValue +    @keywords = [] +    @seenTabToOpenCompletionList = false    updateSelection: -> -    # We retain global state here (previousAutoSelect) to tell if a search item (for which autoSelect is set) -    # has just appeared or disappeared. If that happens, we set @selection to 0 or -1. -    if @completions[0] -      @selection = 0 if @completions[0].autoSelect and not @previousAutoSelect -      @selection = -1 if @previousAutoSelect and not @completions[0].autoSelect -      @previousAutoSelect = @completions[0].autoSelect +    # For custom search engines, we suppress the leading term (e.g. the "w" of "w query terms") within the +    # vomnibar input. +    if @lastReponse.isCustomSearch and not @customSearchMode? +      queryTerms = @input.value.trim().split /\s+/ +      @customSearchMode = queryTerms[0] +      @input.value = queryTerms[1..].join " " + +    # For suggestions for custom search engines, we copy the suggested text into the input when the item is +    # selected, and revert when it is not.  This allows the user to select a suggestion and then continue +    # typing. +    if 0 <= @selection and @completions[@selection].insertText? +      @previousInputValue ?= @input.value +      @input.value = @completions[@selection].insertText +    else if @previousInputValue? +      @input.value = @previousInputValue +      @previousInputValue = null + +    # Highlight the selected entry, and only the selected entry.      for i in [0...@completionList.children.length]        @completionList.children[i].className = (if i == @selection then "vomnibarSelected" else "") -  # -  # Returns the user's action ("up", "down", "enter", "dismiss" or null) based on their keypress. -  # We support the arrow keys and other shortcuts for moving, so this method hides that complexity. -  # +  # Returns the user's action ("up", "down", "tab", etc, or null) based on their keypress.  We support the +  # arrow keys and various other shortcuts, and this function hides the event-decoding complexity.    actionFromKeyEvent: (event) ->      key = KeyboardUtils.getKeyChar(event)      if (KeyboardUtils.isEscape(event)) @@ -90,83 +108,139 @@ class VomnibarUI          (event.shiftKey && event.keyCode == keyCodes.tab) ||          (event.ctrlKey && (key == "k" || key == "p")))        return "up" +    else if (event.keyCode == keyCodes.tab && !event.shiftKey) +      return "tab"      else if (key == "down" || -        (event.keyCode == keyCodes.tab && !event.shiftKey) ||          (event.ctrlKey && (key == "j" || key == "n")))        return "down"      else if (event.keyCode == keyCodes.enter)        return "enter" +    else if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey +      return "delete" + +    null    onKeydown: (event) => -    action = @actionFromKeyEvent(event) +    @lastAction = action = @actionFromKeyEvent event      return true unless action # pass through      openInNewTab = @forceNewTab ||        (event.shiftKey || event.ctrlKey || KeyboardUtils.isPrimaryModifierKey(event))      if (action == "dismiss")        @hide() +    else if action in [ "tab", "down" ] +      if action == "tab" and +        @completer.name == "omni" and +        not @seenTabToOpenCompletionList and +        @input.value.trim().length == 0 +          @seenTabToOpenCompletionList = true +          @update true +      else +        @selection += 1 +        @selection = @initialSelectionValue if @selection == @completions.length +        @updateSelection()      else if (action == "up")        @selection -= 1        @selection = @completions.length - 1 if @selection < @initialSelectionValue        @updateSelection() -    else if (action == "down") -      @selection += 1 -      @selection = @initialSelectionValue if @selection == @completions.length -      @updateSelection()      else if (action == "enter") -      # If they type something and hit enter without selecting a completion from our list of suggestions, -      # try to open their query as a URL directly. If it doesn't look like a URL, we will search using -      # google. -      if (@selection == -1) +      isCustomSearchPrimarySuggestion = @completions[@selection]?.isPrimarySuggestion and @lastReponse.engine?.searchUrl? +      if @selection == -1 or isCustomSearchPrimarySuggestion          query = @input.value.trim() -        # <Enter> on an empty vomnibar is a no-op. +        # <Enter> on an empty query is a no-op.          return unless 0 < query.length -        @hide() -        chrome.runtime.sendMessage({ -          handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" -          url: query }) +        # First case (@selection == -1). +        # If the user types something and hits enter without selecting a completion from the list, then: +        #   - If a search URL has been provided, then use it.  This is custom search engine request. +        #   - Otherwise, send the query to the background page, which will open it as a URL or create a +        #     default search, as appropriate. +        # +        # Second case (isCustomSearchPrimarySuggestion). +        # Alternatively, the selected completion could be the primary selection for a custom search engine. +        # Because the the suggestions are updated asynchronously in omni mode, the user may have typed more +        # text than that which is included in the URL associated with the primary suggestion.  Therefore, to +        # avoid a race condition, we construct the query from the actual contents of the input (query). +        query = Utils.createSearchUrl query, @lastReponse.engine.searchUrl if isCustomSearchPrimarySuggestion +        @hide -> +          chrome.runtime.sendMessage +            handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" +            url: query +      else +        completion = @completions[@selection] +        @hide -> completion.performAction openInNewTab +    else if action == "delete" +      inputIsEmpty = @input.value.length == 0 +      if inputIsEmpty and @customSearchMode? +        # Normally, with custom search engines, the keyword (e,g, the "w" of "w query terms") is suppressed. +        # If the input is empty, then reinstate the keyword (the "w"). +        @input.value = @customSearchMode +        @customSearchMode = null +        @update true +      else if inputIsEmpty and @seenTabToOpenCompletionList +        @seenTabToOpenCompletionList = false +        @update true        else -        @update true, => -          # Shift+Enter will open the result in a new tab instead of the current tab. -          @completions[@selection].performAction(openInNewTab) -          @hide() +        return true # Do not suppress event.      # It seems like we have to manually suppress the event here and still return true.      event.stopImmediatePropagation()      event.preventDefault()      true -  updateCompletions: (callback) -> -    query = @input.value.trim() - -    @completer.filter query, (completions) => -      @completions = completions -      @populateUiWithCompletions(completions) -      callback() if callback - -  populateUiWithCompletions: (completions) -> -    # update completion list with the new data -    @completionList.innerHTML = completions.map((completion) -> "<li>#{completion.html}</li>").join("") -    @completionList.style.display = if completions.length > 0 then "block" else "" -    @selection = Math.min(Math.max(@initialSelectionValue, @selection), @completions.length - 1) -    @updateSelection() - -  update: (updateSynchronously, callback) => -    if (updateSynchronously) -      # cancel scheduled update -      if (@updateTimer != null) -        window.clearTimeout(@updateTimer) -      @updateCompletions(callback) -    else if (@updateTimer != null) -      # an update is already scheduled, don't do anything -      return -    else -      # always update asynchronously for better user experience and to take some load off the CPU -      # (not every keystroke will cause a dedicated update) -      @updateTimer = setTimeout(=> -        @updateCompletions(callback) +  # Return the background-page query corresponding to the current input state.  In other words, reinstate any +  # search engine keyword which is currently being suppressed, and strip any prompted text. +  getInputValueAsQuery: -> +    (if @customSearchMode? then @customSearchMode + " " else "") + @input.value + +  updateCompletions: (callback = null) -> +    @completer.filter +      query: @getInputValueAsQuery() +      seenTabToOpenCompletionList: @seenTabToOpenCompletionList +      callback: (@lastReponse) => +        { results } = @lastReponse +        @completions = results +        @selection = if @completions[0]?.autoSelect then 0 else @initialSelectionValue +        # Update completion list with the new suggestions. +        @completionList.innerHTML = @completions.map((completion) -> "<li>#{completion.html}</li>").join("") +        @completionList.style.display = if @completions.length > 0 then "block" else "" +        @selection = Math.min @completions.length - 1, Math.max @initialSelectionValue, @selection +        @updateSelection() +        callback?() + +  onInput: => +    @seenTabToOpenCompletionList = false +    @completer.cancel() +    if 0 <= @selection and @completions[@selection].customSearchMode and not @customSearchMode +      @customSearchMode = @completions[@selection].customSearchMode +      updateSynchronously = true +    # If the user types, then don't reset any previous text, and reset the selection. +    if @previousInputValue? +      @previousInputValue = null +      @selection = -1 +    @update updateSynchronously + +  clearUpdateTimer: -> +    if @updateTimer? +      window.clearTimeout @updateTimer +      @updateTimer = null + +  shouldActivateCustomSearchMode: -> +    queryTerms = @input.value.ltrim().split /\s+/ +    1 < queryTerms.length and queryTerms[0] in @keywords and not @customSearchMode + +  update: (updateSynchronously = false, callback = null) => +    # If the query text becomes a custom search (the user enters a search keyword), then we need to force a +    # synchronous update (so that the state is updated immediately). +    updateSynchronously ||= @shouldActivateCustomSearchMode() +    if updateSynchronously +      @clearUpdateTimer() +      @updateCompletions callback +    else if not @updateTimer? +      # Update asynchronously for a better user experience, and to take some load off the CPU (not every +      # keystroke will cause a dedicated update). +      @updateTimer = Utils.setTimeout @refreshInterval, =>          @updateTimer = null -      @refreshInterval) +        @updateCompletions callback      @input.focus() @@ -174,58 +248,93 @@ class VomnibarUI      @box = document.getElementById("vomnibar")      @input = @box.querySelector("input") -    @input.addEventListener "input", @update +    @input.addEventListener "input", @onInput      @input.addEventListener "keydown", @onKeydown      @completionList = @box.querySelector("ul")      @completionList.style.display = ""      window.addEventListener "focus", => @input.focus() +    # A click in the vomnibar itself refocuses the input. +    @box.addEventListener "click", (event) => +      @input.focus() +      event.stopImmediatePropagation() +    # A click anywhere else hides the vomnibar. +    document.body.addEventListener "click", => @hide()  # -# Sends filter and refresh requests to a Vomnibox completer on the background page. +# Sends requests to a Vomnibox completer on the background page.  #  class BackgroundCompleter -  # - name: The background page completer that you want to interface with. Either "omni", "tabs", or -  # "bookmarks". */ +  # The "name" is the background-page completer to connect to: "omni", "tabs", or "bookmarks".    constructor: (@name) -> -    @filterPort = chrome.runtime.connect({ name: "filterCompleter" }) - -  refresh: -> chrome.runtime.sendMessage({ handler: "refreshCompleter", name: @name }) - -  filter: (query, callback) -> -    id = Utils.createUniqueId() -    @filterPort.onMessage.addListener (msg) => -      @filterPort.onMessage.removeListener(arguments.callee) -      # The result objects coming from the background page will be of the form: -      #   { html: "", type: "", url: "" } -      # type will be one of [tab, bookmark, history, domain]. -      results = msg.results.map (result) -> -        functionToCall = if (result.type == "tab") -          BackgroundCompleter.completionActions.switchToTab.curry(result.tabId) -        else -          BackgroundCompleter.completionActions.navigateToUrl.curry(result.url) -        result.performAction = functionToCall -        result -      callback(results) - -    @filterPort.postMessage({ id: id, name: @name, query: query }) - -extend BackgroundCompleter, -  # -  # These are the actions we can perform when the user selects a result in the Vomnibox. -  # +    @port = chrome.runtime.connect name: "completions" +    @messageId = null +    @reset() + +    @port.onMessage.addListener (msg) => +      switch msg.handler +        when "keywords" +          @keywords = msg.keywords +          @lastUI.setKeywords @keywords +        when "completions" +          if msg.id == @messageId +            # The result objects coming from the background page will be of the form: +            #   { html: "", type: "", url: "", ... } +            # Type will be one of [tab, bookmark, history, domain, search], or a custom search engine description. +            for result in msg.results +              extend result, +                performAction: +                  if result.type == "tab" +                    @completionActions.switchToTab result.tabId +                  else +                    @completionActions.navigateToUrl result.url + +            # Handle the message, but only if it hasn't arrived too late. +            @mostRecentCallback msg + +  filter: (request) -> +    { query, callback } = request +    @mostRecentCallback = callback + +    @port.postMessage extend request, +      handler: "filter" +      name: @name +      id: @messageId = Utils.createUniqueId() +      queryTerms: query.trim().split(/\s+/).filter (s) -> 0 < s.length +      # We don't send these keys. +      callback: null + +  reset: -> +    @keywords = [] + +  refresh: (@lastUI) -> +    @reset() +    @port.postMessage name: @name, handler: "refresh" + +  cancel: -> +    # Inform the background completer that it may (should it choose to do so) abandon any pending query +    # (because the user is typing, and there will be another query along soon). +    @port.postMessage name: @name, handler: "cancel" + +  # These are the actions we can perform when the user selects a result.    completionActions: -    navigateToUrl: (url, openInNewTab) -> -      # If the URL is a bookmarklet prefixed with javascript:, we shouldn't open that in a new tab. -      openInNewTab = false if url.startsWith("javascript:") -      chrome.runtime.sendMessage( +    navigateToUrl: (url) -> (openInNewTab) -> +      # If the URL is a bookmarklet (so, prefixed with "javascript:"), then we always open it in the current +      # tab. +      openInNewTab &&= not Utils.hasJavascriptPrefix url +      chrome.runtime.sendMessage          handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" -        url: url, -        selected: openInNewTab) +        url: url +        selected: openInNewTab -    switchToTab: (tabId) -> chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: tabId }) +    switchToTab: (tabId) -> -> +      chrome.runtime.sendMessage handler: "selectSpecificTab", id: tabId -UIComponentServer.registerHandler (event) -> Vomnibar.activate event.data +UIComponentServer.registerHandler (event) -> +  switch event.data +    when "hide" then Vomnibar.hide() +    when "hidden" then Vomnibar.onHidden() +    else Vomnibar.activate event.data  root = exports ? window  root.Vomnibar = Vomnibar diff --git a/pages/vomnibar.css b/pages/vomnibar.css index 2042a6c4..1b19daad 100644 --- a/pages/vomnibar.css +++ b/pages/vomnibar.css @@ -126,7 +126,6 @@  #vomnibar li em { font-style: italic; }  #vomnibar li em .vomnibarMatch, #vomnibar li .vomnibarTitle .vomnibarMatch {    color: #333; -  text-decoration: underline;  }  #vomnibar li.vomnibarSelected { @@ -134,3 +133,21 @@    font-weight: normal;  } +#vomnibarInput::selection { +  /* This is the light grey color of the vomnibar border. */ +  /* background-color: #F1F1F1; */ + +  /* This is the light blue color of the vomnibar selected item. */ +  /* background-color: #BBCEE9; */ + +  /* This is a considerably lighter blue than Vimium blue, which seems softer +   * on the eye for this purpose. */ +  background-color: #E6EEFB; +} + +.vomnibarInsertText { +} + +.vomnibarNoInsertText { +  visibility: hidden; +} diff --git a/pages/vomnibar.html b/pages/vomnibar.html index 2ca463d0..87acc081 100644 --- a/pages/vomnibar.html +++ b/pages/vomnibar.html @@ -14,7 +14,7 @@    <body>      <div id="vomnibar" class="vimiumReset">        <div class="vimiumReset vomnibarSearchArea"> -        <input type="text" class="vimiumReset"> +        <input id="vomnibarInput" type="text" class="vimiumReset">        </div>        <ul class="vimiumReset"></ul>      </div> diff --git a/tests/dom_tests/chrome.coffee b/tests/dom_tests/chrome.coffee index d6c03fc1..4c9bfa52 100644 --- a/tests/dom_tests/chrome.coffee +++ b/tests/dom_tests/chrome.coffee @@ -27,3 +27,7 @@ root.chrome =      sync:        get: ->        set: -> +    onChanged: +      addListener: -> +  extension: +    inIncognitoContext: false diff --git a/tests/dom_tests/dom_tests.coffee b/tests/dom_tests/dom_tests.coffee index a1ca8723..8c2b73c3 100644 --- a/tests/dom_tests/dom_tests.coffee +++ b/tests/dom_tests/dom_tests.coffee @@ -1,6 +1,6 @@  # Install frontend event handlers. -initializeWhenEnabled() +installListeners()  installListener = (element, event, callback) ->    element.addEventListener event, (-> callback.apply(this, arguments)), true @@ -84,6 +84,47 @@ createGeneralHintTests = (isFilteredMode) ->  createGeneralHintTests false  createGeneralHintTests true +inputs = [] +context "Test link hints for focusing input elements correctly", + +  setup -> +    initializeModeState() +    testDiv = document.getElementById("test-div") +    testDiv.innerHTML = "" + +    stub settings.values, "filterLinkHints", false +    stub settings.values, "linkHintCharacters", "ab" + +    # Every HTML5 input type except for hidden. We should be able to activate all of them with link hints. +    inputTypes = ["button", "checkbox", "color", "date", "datetime", "datetime-local", "email", "file", +      "image", "month", "number", "password", "radio", "range", "reset", "search", "submit", "tel", "text", +      "time", "url", "week"] + +    for type in inputTypes +      input = document.createElement "input" +      input.type = type +      testDiv.appendChild input +      inputs.push input + +  tearDown -> +    document.getElementById("test-div").innerHTML = "" + +  should "Focus each input when its hint text is typed", -> +    for input in inputs +      input.scrollIntoView() # Ensure the element is visible so we create a link hint for it. + +      activeListener = ensureCalled (event) -> +        input.blur() if event.type == "focus" +      input.addEventListener "focus", activeListener, false +      input.addEventListener "click", activeListener, false + +      LinkHints.activateMode() +      [hint] = getHintMarkers().filter (hint) -> input == hint.clickableItem +      sendKeyboardEvent char for char in hint.hintString + +      input.removeEventListener "focus", activeListener, false +      input.removeEventListener "click", activeListener, false +  context "Alphabetical link hints",    setup -> @@ -115,6 +156,9 @@ context "Alphabetical link hints",      assert.equal "", hintMarkers[0].style.display  context "Filtered link hints", +  # Note.  In all of these tests, the order of the elements returned by getHintMarkers() may be different from +  # the order they are listed in the test HTML content.  This is because LinkHints.activateMode() sorts the +  # elements.    setup ->      stub settings.values, "filterLinkHints", true @@ -164,8 +208,8 @@ context "Filtered link hints",      should "label the images", ->        hintMarkers = getHintMarkers()        assert.equal "1: alt text", hintMarkers[0].textContent.toLowerCase() -      assert.equal "2: alt text", hintMarkers[1].textContent.toLowerCase() -      assert.equal "3: some title", hintMarkers[2].textContent.toLowerCase() +      assert.equal "2: some title", hintMarkers[1].textContent.toLowerCase() +      assert.equal "3: alt text", hintMarkers[2].textContent.toLowerCase()        assert.equal "4", hintMarkers[3].textContent.toLowerCase()    context "Input hints", @@ -187,9 +231,9 @@ context "Filtered link hints",        hintMarkers = getHintMarkers()        assert.equal "1", hintMarkers[0].textContent.toLowerCase()        assert.equal "2", hintMarkers[1].textContent.toLowerCase() -      assert.equal "3", hintMarkers[2].textContent.toLowerCase() +      assert.equal "3: a label", hintMarkers[2].textContent.toLowerCase()        assert.equal "4: a label", hintMarkers[3].textContent.toLowerCase() -      assert.equal "5: a label", hintMarkers[4].textContent.toLowerCase() +      assert.equal "5", hintMarkers[4].textContent.toLowerCase()  context "Input focus", @@ -454,10 +498,6 @@ context "PostFindMode",      testContent = "<input type='text' id='first'/>"      document.getElementById("test-div").innerHTML = testContent      document.getElementById("first").focus() -    # For these tests, we need to push GrabBackFocus out of the way.  When it exits, it updates the badge, -    # which interferes with event suppression within insert mode.  This cannot happen in normal operation, -    # because GrabBackFocus exits on the first keydown. -    Mode.top().exit()      @postFindMode = new PostFindMode    tearDown -> @@ -486,53 +526,3 @@ context "PostFindMode",      sendKeyboardEvent "escape"      assert.isTrue @postFindMode.modeIsActive -context "Mode badges", -  setup -> -    initializeModeState() -    testContent = "<input type='text' id='first'/>" -    document.getElementById("test-div").innerHTML = testContent - -  tearDown -> -    document.getElementById("test-div").innerHTML = "" - -  should "have no badge in normal mode", -> -    Mode.updateBadge() -    assert.isTrue chromeMessages[0].badge == "" - -  should "have an I badge in insert mode by focus", -> -    document.getElementById("first").focus() -    assert.isTrue chromeMessages[0].badge == "I" - -  should "have no badge after leaving insert mode by focus", -> -    document.getElementById("first").focus() -    document.getElementById("first").blur() -    assert.isTrue chromeMessages[0].badge == "" - -  should "have an I badge in global insert mode", -> -    new InsertMode global: true -    assert.isTrue chromeMessages[0].badge == "I" - -  should "have no badge after leaving global insert mode", -> -    mode = new InsertMode global: true -    mode.exit() -    assert.isTrue chromeMessages[0].badge == "" - -  should "have a ? badge in PostFindMode (immediately)", -> -    document.getElementById("first").focus() -    new PostFindMode -    assert.isTrue chromeMessages[0].badge == "?" - -  should "have no badge in PostFindMode (subsequently)", -> -    document.getElementById("first").focus() -    new PostFindMode -    sendKeyboardEvent "a" -    assert.isTrue chromeMessages[0].badge == "" - -  should "have no badge when disabled", -> -    handlerStack.bubbleEvent "registerStateChange", -      enabled: false -      passKeys: "" - -    document.getElementById("first").focus() -    assert.isTrue chromeMessages[0].badge == "" - diff --git a/tests/dom_tests/dom_tests.html b/tests/dom_tests/dom_tests.html index cbd91bca..5ccd39e7 100644 --- a/tests/dom_tests/dom_tests.html +++ b/tests/dom_tests/dom_tests.html @@ -44,6 +44,7 @@      <script type="text/javascript" src="../../content_scripts/mode_insert.js"></script>      <script type="text/javascript" src="../../content_scripts/mode_find.js"></script>      <script type="text/javascript" src="../../content_scripts/mode_visual_edit.js"></script> +    <script type="text/javascript" src="../../content_scripts/hud.js"></script>      <script type="text/javascript" src="../../content_scripts/vimium_frontend.js"></script>      <script type="text/javascript" src="../shoulda.js/shoulda.js"></script> diff --git a/tests/dom_tests/dom_utils_test.coffee b/tests/dom_tests/dom_utils_test.coffee index ad8bde3c..ce8fa370 100644 --- a/tests/dom_tests/dom_utils_test.coffee +++ b/tests/dom_tests/dom_utils_test.coffee @@ -4,19 +4,19 @@ context "Check visibility",      document.getElementById("test-div").innerHTML = """      <div id='foo'>test</div>      """ -    assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'foo') != null +    assert.isTrue (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true) != null    should "detect display:none links as hidden", ->      document.getElementById("test-div").innerHTML = """      <a id='foo' style='display:none'>test</a>      """ -    assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo' +    assert.equal null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true)    should "detect visibility:hidden links as hidden", ->      document.getElementById("test-div").innerHTML = """      <a id='foo' style='visibility:hidden'>test</a>      """ -    assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo' +    assert.equal null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true)    should "detect elements nested in display:none elements as hidden", ->      document.getElementById("test-div").innerHTML = """ @@ -24,7 +24,7 @@ context "Check visibility",        <a id='foo'>test</a>      </div>      """ -    assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo' +    assert.equal null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true)    should "detect links nested in visibility:hidden elements as hidden", ->      document.getElementById("test-div").innerHTML = """ @@ -32,23 +32,23 @@ context "Check visibility",        <a id='foo'>test</a>      </div>      """ -    assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo' +    assert.equal null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true)    should "detect links outside viewport as hidden", ->      document.getElementById("test-div").innerHTML = """      <a id='foo' style='position:absolute;top:-2000px'>test</a>      <a id='bar' style='position:absolute;left:2000px'>test</a>      """ -    assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo' -    assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'bar' +    assert.equal null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true) +    assert.equal null, (DomUtils.getVisibleClientRect (document.getElementById 'bar'), true)    should "detect links only partially outside viewport as visible", ->      document.getElementById("test-div").innerHTML = """      <a id='foo' style='position:absolute;top:-10px'>test</a>      <a id='bar' style='position:absolute;left:-10px'>test</a>      """ -    assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'foo') != null -    assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'bar') != null +    assert.isTrue (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true) != null +    assert.isTrue (DomUtils.getVisibleClientRect (document.getElementById 'bar'), true) != null    should "detect links that contain only floated / absolutely-positioned divs as visible", ->      document.getElementById("test-div").innerHTML = """ @@ -56,14 +56,14 @@ context "Check visibility",        <div style='float:left'>test</div>      </a>      """ -    assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'foo') != null +    assert.isTrue (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true) != null      document.getElementById("test-div").innerHTML = """      <a id='foo'>        <div style='position:absolute;top:0;left:0'>test</div>      </a>      """ -    assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'foo') != null +    assert.isTrue (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true) != null    should "detect links that contain only invisible floated divs as invisible", ->      document.getElementById("test-div").innerHTML = """ @@ -71,7 +71,16 @@ context "Check visibility",        <div style='float:left;visibility:hidden'>test</div>      </a>      """ -    assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo' +    assert.equal null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true) + +  should "detect font-size: 0; and display: inline; links when their children are display: inline", -> +    # This test represents the minimal test case covering issue #1554. +    document.getElementById("test-div").innerHTML = """ +    <a id='foo' style='display: inline; font-size: 0px;'> +      <div style='display: inline; font-size: 16px;'>test</div> +    </a> +    """ +    assert.isTrue (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true) != null    should "detect links inside opacity:0 elements as visible", ->      # XXX This is an expected failure. See issue #16. @@ -80,7 +89,7 @@ context "Check visibility",        <a id='foo'>test</a>      </div>      """ -    assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'foo') != null +    assert.isTrue (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true) != null    should "Detect links within SVGs as visible", ->      # XXX this is an expected failure @@ -91,4 +100,4 @@ context "Check visibility",        </a>      </svg>      """ -    assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo' +    assert.equal null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true) diff --git a/tests/dom_tests/phantom_runner.coffee b/tests/dom_tests/phantom_runner.coffee index 93218724..e0382a35 100644 --- a/tests/dom_tests/phantom_runner.coffee +++ b/tests/dom_tests/phantom_runner.coffee @@ -37,15 +37,20 @@ page.open testfile, (status) ->      console.log 'Unable to load tests.'      phantom.exit 1 -  testsFailed = page.evaluate -> -    Tests.run() -    return Tests.testsFailed - -  if system.args[1] == '--coverage' -    data = page.evaluate -> JSON.stringify _$jscoverage -    fs.write dirname + 'dom_tests_coverage.json', data, 'w' - -  if testsFailed > 0 -    phantom.exit 1 -  else -    phantom.exit 0 +  runTests = -> +    testsFailed = page.evaluate -> +      Tests.run() +      return Tests.testsFailed + +    if system.args[1] == '--coverage' +      data = page.evaluate -> JSON.stringify _$jscoverage +      fs.write dirname + 'dom_tests_coverage.json', data, 'w' + +    if testsFailed > 0 +      phantom.exit 1 +    else +      phantom.exit 0 + +  # We add a short delay to allow asynchronous initialization (that is, initialization which happens on +  # "nextTick") to complete. +  setTimeout runTests, 10 diff --git a/tests/dom_tests/vomnibar_test.coffee b/tests/dom_tests/vomnibar_test.coffee index 0e02bb7b..3eda6234 100644 --- a/tests/dom_tests/vomnibar_test.coffee +++ b/tests/dom_tests/vomnibar_test.coffee @@ -1,4 +1,5 @@  vomnibarFrame = null +SearchEngines.refresh ""  context "Keep selection within bounds", @@ -14,7 +15,7 @@ context "Keep selection within bounds",      oldGetCompleter = vomnibarFrame.Vomnibar.getCompleter.bind vomnibarFrame.Vomnibar      stub vomnibarFrame.Vomnibar, 'getCompleter', (name) =>        completer = oldGetCompleter name -      stub completer, 'filter', (query, callback) => callback(@completions) +      stub completer, 'filter', ({ callback }) => callback results: @completions        completer      # Shoulda.js doesn't support async tests, so we have to hack around. diff --git a/tests/unit_tests/commands_test.coffee b/tests/unit_tests/commands_test.coffee index daaef016..e55dc0f2 100644 --- a/tests/unit_tests/commands_test.coffee +++ b/tests/unit_tests/commands_test.coffee @@ -1,5 +1,6 @@  require "./test_helper.js"  extend global, require "./test_chrome_stubs.js" +global.Settings = {postUpdateHooks: {}}  {Commands} = require "../../background_scripts/commands.js"  context "Key mappings", diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index b7b73cc2..4a0cf746 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -1,5 +1,6 @@  require "./test_helper.js"  extend(global, require "../../lib/utils.js") +extend(global, require "../../background_scripts/completion_engines.js")  extend(global, require "../../background_scripts/completion.js")  extend global, require "./test_chrome_stubs.js" @@ -152,8 +153,9 @@ context "domain completer",    setup ->      @history1 = { title: "history1", url: "http://history1.com", lastVisitTime: hours(1) }      @history2 = { title: "history2", url: "http://history2.com", lastVisitTime: hours(1) } +    @undef    = { title: "history2", url: "http://undefined.net", lastVisitTime: hours(1) } -    stub(HistoryCache, "use", (onComplete) => onComplete([@history1, @history2])) +    stub(HistoryCache, "use", (onComplete) => onComplete([@history1, @history2, @undef]))      global.chrome.history =        onVisited: { addListener: -> }        onVisitRemoved: { addListener: -> } @@ -174,6 +176,9 @@ context "domain completer",    should "returns no results when there's more than one query term, because clearly it's not a domain", ->      assert.arrayEqual [], filterCompleter(@completer, ["his", "tory"]) +  should "not return any results for empty queries", -> +    assert.arrayEqual [], filterCompleter(@completer, []) +  context "domain completer (removing entries)",    setup ->      @history1 = { title: "history1", url: "http://history1.com", lastVisitTime: hours(2) } @@ -231,45 +236,44 @@ context "tab completer",      assert.arrayEqual ["tab2.com"], results.map (tab) -> tab.url      assert.arrayEqual [2], results.map (tab) -> tab.tabId -context "search engines", -  setup -> -    searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s baz description" -    Settings.set 'searchEngines', searchEngines -    @completer = new SearchEngineCompleter() -    # note, I couldn't just call @completer.refresh() here as I couldn't set root.Settings without errors -    # workaround is below, would be good for someone that understands the testing system better than me to improve -    @completer.searchEngines = Settings.getSearchEngines() - -  should "return search engine suggestion without description", -> -    results = filterCompleter(@completer, ["foo", "hello"]) -    assert.arrayEqual ["bar?q=hello"], results.map (result) -> result.url -    assert.arrayEqual ["foo: hello"], results.map (result) -> result.title -    assert.arrayEqual ["search"], results.map (result) -> result.type - -  should "return search engine suggestion with description", -> -    results = filterCompleter(@completer, ["baz", "hello"]) -    assert.arrayEqual ["qux?q=hello"], results.map (result) -> result.url -    assert.arrayEqual ["hello"], results.map (result) -> result.title -    assert.arrayEqual ["baz description"], results.map (result) -> result.type -  context "suggestions",    should "escape html in page titles", -> -    suggestion = new Suggestion(["queryterm"], "tab", "url", "title <span>", returns(1)) -    assert.isTrue suggestion.generateHtml().indexOf("title <span>") >= 0 +    suggestion = new Suggestion +      queryTerms: ["queryterm"] +      type: "tab" +      url: "url" +      title: "title <span>" +      relevancyFunction: returns 1 +    assert.isTrue suggestion.generateHtml({}).indexOf("title <span>") >= 0    should "highlight query words", -> -    suggestion = new Suggestion(["ninj", "words"], "tab", "url", "ninjawords", returns(1)) +    suggestion = new Suggestion +      queryTerms: ["ninj", "words"] +      type: "tab" +      url: "url" +      title: "ninjawords" +      relevancyFunction: returns 1      expected = "<span class='vomnibarMatch'>ninj</span>a<span class='vomnibarMatch'>words</span>" -    assert.isTrue suggestion.generateHtml().indexOf(expected) >= 0 +    assert.isTrue suggestion.generateHtml({}).indexOf(expected) >= 0    should "highlight query words correctly when whey they overlap", -> -    suggestion = new Suggestion(["ninj", "jaword"], "tab", "url", "ninjawords", returns(1)) +    suggestion = new Suggestion +      queryTerms: ["ninj", "jaword"] +      type: "tab" +      url: "url" +      title: "ninjawords" +      relevancyFunction: returns 1      expected = "<span class='vomnibarMatch'>ninjaword</span>s" -    assert.isTrue suggestion.generateHtml().indexOf(expected) >= 0 +    assert.isTrue suggestion.generateHtml({}).indexOf(expected) >= 0    should "shorten urls", -> -    suggestion = new Suggestion(["queryterm"], "tab", "http://ninjawords.com", "ninjawords", returns(1)) -    assert.equal -1, suggestion.generateHtml().indexOf("http://ninjawords.com") +    suggestion = new Suggestion +      queryTerms: ["queryterm"] +      type: "tab" +      url: "http://ninjawords.com" +      title: "ninjawords" +      relevancyFunction: returns 1 +    assert.equal -1, suggestion.generateHtml({}).indexOf("http://ninjawords.com")  context "RankingUtils.wordRelevancy",    should "score higher in shorter URLs", -> @@ -461,7 +465,7 @@ context "TabRecency",  # A convenience wrapper around completer.filter() so it can be called synchronously in tests.  filterCompleter = (completer, queryTerms) ->    results = [] -  completer.filter(queryTerms, (completionResults) -> results = completionResults) +  completer.filter({ queryTerms, query: queryTerms.join " " }, (completionResults) -> results = completionResults)    results  hours = (n) -> 1000 * 60 * 60 * n diff --git a/tests/unit_tests/exclusion_test.coffee b/tests/unit_tests/exclusion_test.coffee index b3ed7194..28c17a2f 100644 --- a/tests/unit_tests/exclusion_test.coffee +++ b/tests/unit_tests/exclusion_test.coffee @@ -14,9 +14,8 @@ root.Marks =  extend(global, require "../../lib/utils.js")  Utils.getCurrentVersion = -> '1.44' -extend(global,require "../../background_scripts/sync.js") -extend(global,require "../../background_scripts/settings.js") -Sync.init() +extend(global,require "../../lib/settings.js") +Settings.init()  extend(global, require "../../background_scripts/exclusions.js")  extend(global, require "../../background_scripts/commands.js")  extend(global, require "../../background_scripts/main.js") diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee index afe862a4..ded7b5f8 100644 --- a/tests/unit_tests/settings_test.coffee +++ b/tests/unit_tests/settings_test.coffee @@ -3,15 +3,18 @@ extend global, require "./test_chrome_stubs.js"  extend(global, require "../../lib/utils.js")  Utils.getCurrentVersion = -> '1.44' +Utils.isBackgroundPage = -> true +Utils.isExtensionPage = -> true  global.localStorage = {} -extend(global,require "../../background_scripts/sync.js") -extend(global,require "../../background_scripts/settings.js") -Sync.init() +extend(global,require "../../lib/settings.js")  context "settings",    setup ->      stub global, 'localStorage', {} +    Settings.cache = global.localStorage # Point the settings cache to the new localStorage object. +    Settings.postUpdateHooks = {} # Avoid running update hooks which include calls to outside of settings. +    Settings.init()    should "save settings in localStorage as JSONified strings", ->      Settings.set 'dummy', "" @@ -39,24 +42,22 @@ context "settings",    should "propagate non-default value via synced storage listener", ->      Settings.set 'scrollStepSize', 20      assert.equal Settings.get('scrollStepSize'), 20 -    Sync.handleStorageUpdate { scrollStepSize: { newValue: "40" } } +    Settings.Sync.handleStorageUpdate { scrollStepSize: { newValue: "40" } }      assert.equal Settings.get('scrollStepSize'), 40    should "propagate default value via synced storage listener", ->      Settings.set 'scrollStepSize', 20      assert.equal Settings.get('scrollStepSize'), 20 -    Sync.handleStorageUpdate { scrollStepSize: { newValue: "60" } } +    Settings.Sync.handleStorageUpdate { scrollStepSize: { newValue: "60" } }      assert.isFalse Settings.has 'scrollStepSize'    should "propagate non-default values from synced storage", ->      chrome.storage.sync.set { scrollStepSize: JSON.stringify(20) } -    Sync.fetchAsync()      assert.equal Settings.get('scrollStepSize'), 20    should "propagate default values from synced storage", ->      Settings.set 'scrollStepSize', 20      chrome.storage.sync.set { scrollStepSize: JSON.stringify(60) } -    Sync.fetchAsync()      assert.isFalse Settings.has 'scrollStepSize'    should "clear a setting from synced storage", -> @@ -66,19 +67,10 @@ context "settings",    should "trigger a postUpdateHook", ->      message = "Hello World" -    Settings.postUpdateHooks['scrollStepSize'] = (value) -> Sync.message = value +    receivedMessage = "" +    Settings.postUpdateHooks['scrollStepSize'] = (value) -> receivedMessage = value      chrome.storage.sync.set { scrollStepSize: JSON.stringify(message) } -    assert.equal message, Sync.message - -  should "set search engines, retrieve them correctly and check that they have been parsed correctly", -> -    searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s baz description" -    Settings.set 'searchEngines', searchEngines -    result = Settings.getSearchEngines() -    assert.equal Object.keys(result).length, 2 -    assert.equal "bar?q=%s", result["foo"].url -    assert.isFalse result["foo"].description -    assert.equal "qux?q=%s", result["baz"].url -    assert.equal "baz description", result["baz"].description +    assert.equal message, receivedMessage    should "sync a key which is not a known setting (without crashing)", ->      chrome.storage.sync.set { notASetting: JSON.stringify("notAUsefullValue") } diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index c61d7246..16f0e144 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -16,6 +16,11 @@ exports.chrome =        addListener: () -> true      onMessage:        addListener: () -> true +    onInstalled: +      addListener: -> + +  extension: +    getURL: (path) -> path    tabs:      onSelectionChanged: @@ -36,6 +41,12 @@ exports.chrome =        addListener: () -> true      query: () -> true +  webNavigation: +    onHistoryStateUpdated: +      addListener: () -> +    onReferenceFragmentUpdated: +      addListener: () -> +    windows:      onRemoved:        addListener: () -> true diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee index 88e9a15b..f9ed3636 100644 --- a/tests/unit_tests/utils_test.coffee +++ b/tests/unit_tests/utils_test.coffee @@ -2,9 +2,8 @@ require "./test_helper.js"  extend global, require "./test_chrome_stubs.js"  extend(global, require "../../lib/utils.js")  Utils.getCurrentVersion = -> '1.43' -extend(global, require "../../background_scripts/sync.js") -extend(global, require "../../background_scripts/settings.js") -Sync.init() +extend(global, require "../../lib/settings.js") +Settings.init()  context "isUrl",    should "accept valid URLs", -> @@ -42,11 +41,22 @@ context "convertToUrl",      assert.equal "http://127.0.0.1:8080", Utils.convertToUrl("127.0.0.1:8080")      assert.equal "http://[::]:8080", Utils.convertToUrl("[::]:8080")      assert.equal "view-source:    0.0.0.0", Utils.convertToUrl("view-source:    0.0.0.0") +    assert.equal "javascript:alert('25 % 20 * 25 ');", Utils.convertToUrl "javascript:alert('25 % 20 * 25%20');"    should "convert non-URL terms into search queries", -> -    assert.equal "http://www.google.com/search?q=google", Utils.convertToUrl("google") -    assert.equal "http://www.google.com/search?q=go+ogle.com", Utils.convertToUrl("go ogle.com") -    assert.equal "http://www.google.com/search?q=%40twitter", Utils.convertToUrl("@twitter") +    assert.equal "https://www.google.com/search?q=google", Utils.convertToUrl("google") +    assert.equal "https://www.google.com/search?q=go+ogle.com", Utils.convertToUrl("go ogle.com") +    assert.equal "https://www.google.com/search?q=%40twitter", Utils.convertToUrl("@twitter") + +context "extractQuery", +  should "extract queries from search URLs", -> +    assert.equal "bbc sport 1", Utils.extractQuery "https://www.google.ie/search?q=%s", "https://www.google.ie/search?q=bbc+sport+1" +    assert.equal "bbc sport 2", Utils.extractQuery "http://www.google.ie/search?q=%s", "https://www.google.ie/search?q=bbc+sport+2" +    assert.equal "bbc sport 3", Utils.extractQuery "https://www.google.ie/search?q=%s", "http://www.google.ie/search?q=bbc+sport+3" +    assert.equal "bbc sport 4", Utils.extractQuery "https://www.google.ie/search?q=%s", "http://www.google.ie/search?q=bbc+sport+4&blah" + +  should "extract not queries from incorrect search URLs", -> +    assert.isFalse Utils.extractQuery "https://www.google.ie/search?q=%s&foo=bar", "https://www.google.ie/search?q=bbc+sport"  context "hasChromePrefix",    should "detect chrome prefixes of URLs", -> @@ -62,6 +72,17 @@ context "hasChromePrefix",      assert.isFalse Utils.hasChromePrefix "data"      assert.isFalse Utils.hasChromePrefix "data :foobar" +context "hasJavascriptPrefix", +  should "detect javascript: URLs", -> +    assert.isTrue Utils.hasJavascriptPrefix "javascript:foobar" +    assert.isFalse Utils.hasJavascriptPrefix "http:foobar" + +context "decodeURIByParts", +  should "decode javascript: URLs", -> +    assert.equal "foobar", Utils.decodeURIByParts "foobar" +    assert.equal " ", Utils.decodeURIByParts "%20" +    assert.equal "25 % 20 25 ", Utils.decodeURIByParts "25 % 20 25%20" +  context "isUrl",    should "identify URLs as URLs", ->      assert.isTrue Utils.isUrl "http://www.example.com/blah" | 
