diff options
35 files changed, 1612 insertions, 583 deletions
@@ -141,6 +141,16 @@ Please see [CONTRIBUTING.md](https://github.com/philc/vimium/blob/master/CONTRIB Release Notes ------------- +1.52 (not yet released) + +- Search engine completion for selected search engines (including Google, Youtube, Bing, DuckDuckGo, Wikipedia and Amazon). +- Much improved custom search engine experience (including completion, where available). +- 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 diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index bca1c3a4..abfbd9e2 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -321,8 +321,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 }] diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index 6a1c0d30..02c5478a 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -5,39 +5,58 @@ # 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: -> 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. # 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> + <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 vomnibarUrl">#{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))}</span> + <span class="vimiumReset vomnibarSource vomnibarNoInsertText">#{insertTextIndicator}</span><span class="vimiumReset vomnibarUrl">#{@highlightUrlTerms Utils.escapeHtml @shortenUrl()}</span> #{relevancyHtml} </div> """ @@ -48,7 +67,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 +99,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 +118,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 +134,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 +176,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 +194,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 +237,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 +276,9 @@ class DomainCompleter # If `referenceCount` goes to zero, the domain entry can and should be deleted. domains: null - filter: (queryTerms, onComplete) -> - return onComplete([]) unless 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 +286,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: 1 + ].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 +336,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 +379,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,88 +401,282 @@ 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 + searchEngines: null + 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 + request.searchEngines = 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] + + refresh: (port) -> + @previousSuggestions = {} + # Parse the search-engine configuration. + @searchEngines = new AsyncDataFetcher (callback) -> + engines = {} + for line in Settings.get("searchEngines").split "\n" + line = line.trim() + continue if /^[#"]/.test line + tokens = line.split /\s+/ + continue unless 2 <= tokens.length + keyword = tokens[0].split(":")[0] + url = tokens[1] + description = tokens[2..].join(" ") || "search (#{keyword})" + continue unless Utils.hasFullUrlPrefix url + engines[keyword] = + keyword: keyword + searchUrl: url + description: description + searchUrlPrefix: url.split("%s")[0] + + callback 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 + + { custom, searchUrl, description } = + if engine + { keyword, searchUrl, description } = engine + extend request, { searchUrl, customSearchMode: true } + custom: true + searchUrl: searchUrl + description: description 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 + custom: false + searchUrl: Settings.get "searchUrl" + description: "search" + + return onComplete [] unless custom or 0 < queryTerms.length + + factor = Math.max 0.0, Math.min 1.0, Settings.get "omniSearchWeight" + haveCompletionEngine = (0.0 < factor or custom) and 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) -> + if custom and haveCompletionEngine + # We only accept suggestions: + # - from this completer, or + # - from other completers, but then only if their URL matches this search engine and matches this + # query (that is only if their URL could have been generated by this search engine). + suggestions.filter (suggestion) -> + suggestion.type == description or + # This is a suggestion for the same search engine. + (suggestion.url.startsWith(engine.searchUrlPrefix) and + # And the URL suffix (which must contain the query part) matches the current query. + RankingUtils.matches queryTerms, suggestion.url[engine.searchUrlPrefix.length..]) + + else if not custom + # Filter out any suggestion which is just what the user would get if they hit <Enter> anyway. For + # example, don't offer "https://www.google.com/search?q=vimium" if the query is "vimium". + defaultUrl = Utils.createSearchUrl queryTerms, searchUrl + defaultQuery = queryTerms.join " " + suggestions.filter (suggestion) -> Utils.extractQuery(searchUrl, suggestion.url) != defaultQuery + else + suggestions + + # 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 = + 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 - refresh: -> - @searchEngines = SearchEngineCompleter.getSearchEngines() - - getSearchEngineMatches: (queryTerms) -> - (1 < queryTerms.length and @searchEngines[queryTerms[0]]) or {} - - # Static data and methods for parsing the configured search engines. We keep a cache of the search-engine - # mapping in @searchEnginesMap. - @searchEnginesMap: null - - # Parse the custom search engines setting and cache it in SearchEngineCompleter.searchEnginesMap. - @parseSearchEngines: (searchEnginesText) -> - searchEnginesMap = SearchEngineCompleter.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: -> - unless SearchEngineCompleter.searchEnginesMap? - SearchEngineCompleter.parseSearchEngines Settings.get "searchEngines" - SearchEngineCompleter.searchEnginesMap + primarySuggestion = new Suggestion + queryTerms: queryTerms + type: description + url: Utils.createSearchUrl queryTerms, searchUrl + title: queryTerms.join " " + relevancy: 1 + autoSelect: custom + highlightTerms: not haveCompletionEngine + isSearchSuggestion: true + + mkSuggestion = (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: custom + relevancyFunction: @computeRelevancy + relevancyData: factor + + cachedSuggestions = + if haveCompletionEngine then CompletionSearch.complete searchUrl, queryTerms else null + + suggestions = previousSuggestions + suggestions.push primarySuggestion if custom + suggestions.push cachedSuggestions.map(mkSuggestion)... if custom and cachedSuggestions? + + if queryTerms.length == 0 or cachedSuggestions? or not haveCompletionEngine + # There is no prospect of adding further completions. + 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: (suggestions, onComplete) => + + # We can skip querying the completion engine if any new suggestions we propose will not score highly + # enough to make the list anyway. We construct a suggestion which perfectly matches the query, and + # ask the relevancy function what score it would get. If that score is less than the score of the + # lowest-ranked suggestion from another completer (and there are already 10 suggestions), then + # there's no need to query the completion engine. + perfectRelevancyScore = @computeRelevancy new Suggestion + queryTerms: queryTerms, title: queryTerms.join(" "), relevancyData: factor + + if 10 <= suggestions.length and perfectRelevancyScore < suggestions[suggestions.length-1].relevancy + console.log "skip (cannot make the grade):", suggestions.length, query if SearchEngineCompleter.debug + return 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, + relevancyData * 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 + # NOTE(smblott) The following is disabled: experimentation with UI. + # suggestion.highlightTermsExcludeUrl = true + # suggestion.type = engine.description ? "custom search history" + 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 suggestions, (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() for suggestion in suggestions suggestions # Utilities which help us compute a relevancy score for a given item. @@ -551,8 +825,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..189f66f3 --- /dev/null +++ b/background_scripts/completion_engines.coffee @@ -0,0 +1,133 @@ + +# 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: (@regexps) -> + match: (searchUrl) -> Utils.matchesAnyRegexp @regexps, searchUrl + +# Several Google completion engines package XML responses in this way. +class GoogleXMLRegexpEngine extends RegexpEngine + doNotCache: false # true (disbaled, experimental) + 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: -> + super [ + # We match the major English-speaking TLDs. + new RegExp "^https?://[a-z]+\.google\.(com|ie|co\.uk|ca|com\.au)/" + new RegExp "localhost/cgi-bin/booky" # Only for smblott. + ] + + getUrl: (queryTerms) -> + Utils.createSearchUrl queryTerms, + "http://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=%s" + +class Youtube extends GoogleXMLRegexpEngine + # Example search URL: http://www.youtube.com/results?search_query=%s + constructor: -> + super [ new RegExp "^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 + doNotCache: false # true (disbaled, experimental) + # Example search URL: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s + constructor: -> + super [ new RegExp "^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] + +## Does not work... +## class GoogleMaps extends RegexpEngine +## # Example search URL: https://www.google.com/maps/search/%s +## constructor: -> +## super [ new RegExp "^https?://www\.google\.com/maps/search/" ] +## +## getUrl: (queryTerms) -> +## "https://www.google.com/s?tbm=map&fp=1&gs_ri=maps&source=hp&suggest=p&authuser=0&hl=en&pf=p&tch=1&ech=2&q=#{Utils.createSearchQuery queryTerms}" +## +## parse: (xhr) -> +## data = JSON.parse xhr.responseText +## [] + +class Bing extends RegexpEngine + # Example search URL: https://www.bing.com/search?q=%s + constructor: -> super [ new RegExp "^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 [ new RegExp "^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 [ new RegExp "^https?://([a-z]+\.)?duckduckgo\.com/" ] + getUrl: (queryTerms) -> + 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 [ new RegExp "^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 + 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 b3a3960d..5ec76e2a 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 @@ -44,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. diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index e782a217..a13d9d98 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -16,7 +16,7 @@ chrome.runtime.onInstalled.addListener ({ reason }) -> for tab in tabs for [ func, files ] in jobs for file in files - func tab.id, { file: file, allFrames: contentScripts.allFrames }, checkLastRuntimeError + func tab.id, { file: file, allFrames: contentScripts.all_frames }, checkLastRuntimeError currentVersion = Utils.getCurrentVersion() tabQueue = {} # windowId -> Array @@ -43,20 +43,36 @@ 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]) + 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 @@ -215,13 +231,6 @@ handleSettings = (request, port) -> values[key] = Settings.get key for own key of values port.postMessage { values } -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 })) - chrome.tabs.onSelectionChanged.addListener (tabId, selectionInfo) -> if (selectionChangedHandlers.length > 0) selectionChangedHandlers.pop().call() @@ -259,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) @@ -302,13 +311,9 @@ 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. @@ -340,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. @@ -389,7 +396,8 @@ setIcon = (request, sender) -> 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 @@ -602,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 @@ -613,14 +624,21 @@ 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]? and request.frameId in frameIdsForTab[tabId] - frameIdsForTab[tabId] = - [request.frameId, (frameIdsForTab[tabId].filter (id) -> id != request.frameId)...] + if frameIdsForTab[tabId]? + 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 @@ -633,7 +651,7 @@ bgLog = (request, sender) -> portHandlers = keyDown: handleKeyDown, settings: handleSettings, - filterCompleter: filterCompleter + completions: handleCompletions sendRequestHandlers = getCompletionKeys: getCompletionKeysRequest @@ -651,7 +669,6 @@ sendRequestHandlers = pasteFromClipboard: pasteFromClipboard isEnabledForUrl: isEnabledForUrl selectSpecificTab: selectSpecificTab - refreshCompleter: refreshCompleter createMark: Marks.create.bind(Marks) gotoMark: Marks.goto.bind(Marks) setIcon: setIcon @@ -672,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')) diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index a4d95c81..269b4a2c 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -32,9 +32,6 @@ root.Settings = Settings = root.Commands.parseCustomKeyMappings value root.refreshCompletionKeysAfterMappingSave() - searchEngines: (value) -> - root.SearchEngineCompleter.parseSearchEngines value - exclusionRules: (value) -> root.Exclusions.postUpdateHook value @@ -46,6 +43,7 @@ root.Settings = Settings = # or strings defaults: scrollStepSize: 60 + omniSearchWeight: 0.4 smoothScroll: true keyMappings: "# Insert your preferred key mappings here." linkHintCharacters: "sadfjklewcmpgh" @@ -88,9 +86,26 @@ root.Settings = Settings = # "\bnext\b,\bmore\b,>,→,»,≫,>>" nextPatterns: "next,more,>,\u2192,\xbb,\u226b,>>" # default/fall back search engine - searchUrl: "http://www.google.com/search?q=" + 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" + 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 diff --git a/content_scripts/hud.coffee b/content_scripts/hud.coffee new file mode 100644 index 00000000..e07d0713 --- /dev/null +++ b/content_scripts/hud.coffee @@ -0,0 +1,93 @@ +# +# 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" + @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 51e5df35..3cebac4c 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -60,6 +60,12 @@ LinkHints = # 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) @@ -197,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} @@ -238,8 +244,6 @@ LinkHints = # Remove rects from elements where another clickable element lies above it. nonOverlappingElements = [] # Traverse the DOM from first to last, since later elements show above earlier elements. - # NOTE(smblott). filterHints.generateLinkText also assumes this order when generating the content text for - # each hint. Specifically, we consider descendents before we consider their ancestors. visibleElements = visibleElements.reverse() while visibleElement = visibleElements.pop() rects = [visibleElement.rect] @@ -262,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 diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee index a2ac5b8c..f631b4cd 100644 --- a/content_scripts/mode.coffee +++ b/content_scripts/mode.coffee @@ -57,7 +57,8 @@ class Mode # undefined, then the request propagates to the next mode. # The active indicator can also be changed with @setIndicator(). if @options.indicator? - if @options.indicator then HUD?.show @options.indicator else HUD?.hide true, false + if HUD?.isReady() + if @options.indicator then HUD.show @options.indicator else HUD.hide true, false @stopBubblingAndTrue else @continueBubbling diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee index 7ca2e561..4e03cdd5 100644 --- a/content_scripts/mode_insert.coffee +++ b/content_scripts/mode_insert.coffee @@ -19,6 +19,9 @@ 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 @@ -53,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 diff --git a/content_scripts/mode_visual_edit.coffee b/content_scripts/mode_visual_edit.coffee index 23cb64b2..8d1d96cc 100644 --- a/content_scripts/mode_visual_edit.coffee +++ b/content_scripts/mode_visual_edit.coffee @@ -655,9 +655,9 @@ class EditMode extends Movement @element = document.activeElement return unless @element and DomUtils.isEditable @element + options.indicator = "Edit mode" defaults = name: "edit" - indicator: "Edit mode" exitOnEscape: true exitOnBlur: @element super extend defaults, options diff --git a/content_scripts/ui_component.coffee b/content_scripts/ui_component.coffee index fc40e1ca..ba141b23 100644 --- a/content_scripts/ui_component.coffee +++ b/content_scripts/ui_component.coffee @@ -3,18 +3,45 @@ class UIComponent iframePort: null showing: null options: null + shadowDOM: null constructor: (iframeUrl, className, @handleMessage) -> + styleSheet = document.createElement "style" + 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. 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. @@ -22,43 +49,37 @@ class UIComponent @postMessage "hide" if @showing and request.name == "frameFocused" and request.focusFrameId != frameId false # Free up the sendResponse handler. - # 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 + # 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 if @options? - @show() unless @showing - @iframeElement.focus() + @postMessage @options, => + @show() unless @showing + @iframeElement.focus() show: (message) -> - @postMessage message if message? - @iframeElement.classList.remove "vimiumUIComponentHidden" - @iframeElement.classList.add "vimiumUIComponentShowing" - # 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 + @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)-> @refocusSourceFrame @options?.sourceFrameId if focusWindow window.removeEventListener "focus", @onFocus if @onFocus @onFocus = null - @iframeElement.classList.remove "vimiumUIComponentShowing" + @iframeElement.classList.remove "vimiumUIComponentVisible" @iframeElement.classList.add "vimiumUIComponentHidden" @options = null @showing = false diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css index e72eaec8..647c8025 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 { @@ -223,6 +237,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 +253,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 { @@ -275,7 +282,7 @@ body.vimiumFindMode ::selection { /* Vomnibar Frame CSS */ iframe.vomnibarFrame { - background-color: transparent !important; + background-color: transparent; padding: 0px; overflow: hidden; diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index b322af53..41fb772b 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -133,6 +133,32 @@ 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 @@ -158,8 +184,6 @@ initializePreDomReady = -> settings.addEventListener("load", LinkHints.init.bind(LinkHints)) settings.load() - # Note. checkIfEnabledForUrl() must come after initializeModes(), here, because checkIfEnabledForUrl() may - # install an additional mode (GrabBackFocus). initializeModes() checkIfEnabledForUrl() refreshCompletionKeys() @@ -230,7 +254,7 @@ window.installListeners = -> do (type) -> installListener window, type, (event) -> handlerStack.bubbleEvent type, event installListener document, "DOMActivate", (event) -> handlerStack.bubbleEvent 'DOMActivate', event installedListeners = true - # Other one-time initialization operations. + # Other once-only initialisation. FindModeHistory.init() new GrabBackFocus if isEnabledForUrl @@ -260,6 +284,7 @@ initializeOnDomReady = -> CursorHider.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. @@ -303,20 +328,35 @@ setScrollPosition = (scrollX, scrollY) -> # # Called from the backend in order to change frame focus. # -window.focusThisFrame = (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 document.body and 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" @@ -398,7 +438,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 } @@ -466,6 +506,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. @@ -677,21 +718,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) @@ -827,8 +863,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) @@ -1101,89 +1135,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 - - # 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 = "" - - # - # 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 - - createHudElement: -> - element = document.createElement("div") - element.className = "vimiumReset vimiumHUD" - document.body.appendChild(element) - element - - # 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) -> - clearInterval(HUD._tweenId) - if immediate - HUD.displayElement().style.display = "none" unless updateIndicator - Mode.setIndicator() if updateIndicator - else - HUD._tweenId = Tween.fade HUD.displayElement(), 0, 150, -> HUD.hide true, updateIndicator - - 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. @@ -1231,7 +1182,6 @@ window.onbeforeunload = -> root = exports ? window root.settings = settings -root.HUD = HUD root.handlerStack = handlerStack root.frameId = frameId root.windowIsFocused = windowIsFocused diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index efb125f6..7c47179c 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -55,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 @@ -80,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 diff --git a/lib/utils.coffee b/lib/utils.coffee index 64c87842..65e26b7a 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -26,16 +26,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 +107,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 +142,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 +206,29 @@ 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 + + # 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 +239,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 +248,83 @@ 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.SimpleCache = SimpleCache +root.AsyncDataFetcher = AsyncDataFetcher +root.JobRunner = JobRunner diff --git a/manifest.json b/manifest.json index 1d5a2df4..fe5c69ca 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Vimium", - "version": "1.50", + "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", @@ -14,6 +14,8 @@ "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" @@ -50,6 +52,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"], @@ -68,6 +71,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/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 b3ecf69a..18ff226d 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -261,6 +261,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 diff --git a/pages/options.css b/pages/options.css index 5b098c8f..1a3ff757 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 f89ddcbb..8535823d 100644 --- a/pages/options.html +++ b/pages/options.html @@ -71,6 +71,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"> @@ -233,6 +236,33 @@ b: http://b.com/?q=%s description <div class="nonEmptyTextOption"> </td> </tr> + + <!-- 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/vomnibar.coffee b/pages/vomnibar.coffee index b133b126..423ffa59 100644 --- a/pages/vomnibar.coffee +++ b/pages/vomnibar.coffee @@ -9,13 +9,8 @@ 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" @@ -23,20 +18,17 @@ Vomnibar = newTab: false selectFirst: false 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.update true hide: -> @vomnibarUI?.hide() onHidden: -> @vomnibarUI?.onHidden() @@ -48,18 +40,11 @@ class VomnibarUI @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 + 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. @@ -71,32 +56,47 @@ class VomnibarUI 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.customSearchMode 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)) @@ -105,83 +105,131 @@ 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) + if @selection == -1 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 + # 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. + query = Utils.createSearchUrl query, @lastReponse.searchUrl if @lastReponse.searchUrl? @hide -> chrome.runtime.sendMessage handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" url: query else - @update true, => - # Shift+Enter will open the result in a new tab instead of the current tab. - completion = @completions[@selection] - @hide -> completion.performAction openInNewTab + 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 + 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() @@ -189,7 +237,7 @@ 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 = "" @@ -203,48 +251,73 @@ class VomnibarUI 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) -> switch event.data diff --git a/pages/vomnibar.css b/pages/vomnibar.css index 2042a6c4..b1ed0252 100644 --- a/pages/vomnibar.css +++ b/pages/vomnibar.css @@ -134,3 +134,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/dom_tests.coffee b/tests/dom_tests/dom_tests.coffee index bb09a0a8..8c2b73c3 100644 --- a/tests/dom_tests/dom_tests.coffee +++ b/tests/dom_tests/dom_tests.coffee @@ -156,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 @@ -205,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", @@ -228,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", 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..380175f3 100644 --- a/tests/dom_tests/vomnibar_test.coffee +++ b/tests/dom_tests/vomnibar_test.coffee @@ -14,7 +14,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/completion_test.coffee b/tests/unit_tests/completion_test.coffee index 56fcc456..88df0a43 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" @@ -235,44 +236,43 @@ 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 = SearchEngineCompleter.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)) + 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 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 should "shorten urls", -> - suggestion = new Suggestion(["queryterm"], "tab", "http://ninjawords.com", "ninjawords", returns(1)) + 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", @@ -465,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/settings_test.coffee b/tests/unit_tests/settings_test.coffee index 346c98da..4cd20211 100644 --- a/tests/unit_tests/settings_test.coffee +++ b/tests/unit_tests/settings_test.coffee @@ -70,15 +70,5 @@ context "settings", 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 = SearchEngineCompleter.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 - 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/utils_test.coffee b/tests/unit_tests/utils_test.coffee index 88e9a15b..bfe066c3 100644 --- a/tests/unit_tests/utils_test.coffee +++ b/tests/unit_tests/utils_test.coffee @@ -42,11 +42,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 +73,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" |
