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" |
