diff options
33 files changed, 642 insertions, 529 deletions
@@ -89,6 +89,8 @@ Additional advanced browsing commands: gU go up to root of the URL hierarchy zH scroll all the way left zL scroll all the way right + v enter visual mode; use p/P to paste-and-go, use y to yank + V enter visual line mode Vimium supports command repetition so, for example, hitting `5t` will open 5 tabs in rapid succession. `<ESC>` (or `<c-[>`) will clear any partial commands in the queue and will also exit insert and find modes. @@ -144,8 +146,9 @@ 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). +- Improved custom-search engine experience (including completion for Google, + Youtube, Bing, DuckDuckGo, Wikipedia, Amazon and a number of other search + engines). - Bug fixes: bookmarklets accessed from the vomnibar. 1.51 (2015-05-02) diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index 708fe5d5..64ec36be 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -24,21 +24,13 @@ Commands = noRepeat: options.noRepeat repeatLimit: options.repeatLimit - mapKeyToCommand: (key, command) -> + mapKeyToCommand: ({ key, command, options }) -> unless @availableCommands[command] - console.log(command, "doesn't exist!") + console.log command, "doesn't exist!" return - commandDetails = @availableCommands[command] - - @keyToCommandRegistry[key] = - command: command - isBackgroundCommand: commandDetails.isBackgroundCommand - passCountToFunction: commandDetails.passCountToFunction - noRepeat: commandDetails.noRepeat - repeatLimit: commandDetails.repeatLimit - - unmapKey: (key) -> delete @keyToCommandRegistry[key] + options ?= [] + @keyToCommandRegistry[key] = extend { command, options }, @availableCommands[command] # Lower-case the appropriate portions of named keys. # @@ -55,37 +47,29 @@ Commands = .replace /<space>/ig, " " parseCustomKeyMappings: (customKeyMappings) -> - lines = customKeyMappings.split("\n") - - for line in lines - continue if (line[0] == "\"" || line[0] == "#") - splitLine = line.replace(/\s+$/, "").split(/\s+/) - - lineCommand = splitLine[0] - - if (lineCommand == "map") - continue if (splitLine.length != 3) - key = @normalizeKey(splitLine[1]) - vimiumCommand = splitLine[2] - - continue unless @availableCommands[vimiumCommand] - - console.log("Mapping", key, "to", vimiumCommand) - @mapKeyToCommand(key, vimiumCommand) - else if (lineCommand == "unmap") - continue if (splitLine.length != 2) - - key = @normalizeKey(splitLine[1]) - console.log("Unmapping", key) - @unmapKey(key) - else if (lineCommand == "unmapAll") - @keyToCommandRegistry = {} + for line in customKeyMappings.split "\n" + unless line[0] == "\"" or line[0] == "#" + tokens = line.replace(/\s+$/, "").split /\s+/ + switch tokens[0] + when "map" + [ _, key, command, options... ] = tokens + if command? and @availableCommands[command] + key = @normalizeKey key + console.log "Mapping", key, "to", command + @mapKeyToCommand { key, command, options } + + when "unmap" + if tokens.length == 2 + key = @normalizeKey tokens[1] + console.log "Unmapping", key + delete @keyToCommandRegistry[key] + + when "unmapAll" + @keyToCommandRegistry = {} clearKeyMappingsAndSetDefaults: -> @keyToCommandRegistry = {} - - for key of defaultKeyMappings - @mapKeyToCommand(key, defaultKeyMappings[key]) + @mapKeyToCommand { key, command } for key, command of defaultKeyMappings # An ordered listing of all available commands, grouped by type. This is the order they will # be shown in the help page. @@ -122,19 +106,20 @@ Commands = "LinkHints.activateModeWithQueue", "LinkHints.activateModeToDownloadLink", "LinkHints.activateModeToOpenIncognito", - "Vomnibar.activate", - "Vomnibar.activateInNewTab", - "Vomnibar.activateTabSelection", - "Vomnibar.activateBookmarks", - "Vomnibar.activateBookmarksInNewTab", "goPrevious", "goNext", "nextFrame", "mainFrame", "Marks.activateCreateMode", - "Vomnibar.activateEditUrl", - "Vomnibar.activateEditUrlInNewTab", "Marks.activateGotoMode"] + vomnibarCommands: + ["Vomnibar.activate", + "Vomnibar.activateInNewTab", + "Vomnibar.activateTabSelection", + "Vomnibar.activateBookmarks", + "Vomnibar.activateBookmarksInNewTab", + "Vomnibar.activateEditUrl", + "Vomnibar.activateEditUrlInNewTab"] findCommands: ["enterFindMode", "performFind", "performBackwardsFind"] historyNavigation: ["goBack", "goForward"] @@ -322,8 +307,8 @@ commandDescriptions = goToRoot: ["Go to root of current URL hierarchy", { passCountToFunction: true }] # Manipulating tabs - nextTab: ["Go one tab right", { background: true }] - previousTab: ["Go one tab left", { background: true }] + nextTab: ["Go one tab right", { background: true, passCountToFunction: true }] + previousTab: ["Go one tab left", { background: true, passCountToFunction: true }] firstTab: ["Go to the first tab", { background: true }] lastTab: ["Go to the last tab", { background: true }] @@ -360,5 +345,11 @@ commandDescriptions = Commands.init() +# Register postUpdateHook for keyMappings setting. +Settings.postUpdateHooks["keyMappings"] = (value) -> + Commands.clearKeyMappingsAndSetDefaults() + Commands.parseCustomKeyMappings value + refreshCompletionKeysAfterMappingSave() + root = exports ? window root.Commands = Commands diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index 98a82f88..c83066a6 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -29,6 +29,8 @@ class Suggestion @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) @@ -41,24 +43,33 @@ class Suggestion # or @relevancyFunction. @relevancy ?= @relevancyFunction this - generateHtml: -> + generateHtml: (request) -> return @html if @html relevancyHtml = if @showRelevancy then "<span class='relevancy'>#{@computeRelevancy()}</span>" else "" - # NOTE(philc): We're using these vimium-specific class names so we don't collide with the page's CSS. insertTextClass = if @insertText then "vomnibarInsertText" else "vomnibarNoInsertText" - insertTextIndicator = "﹢" # A small plus sign. - insertTextIndicator = "﹥" # A small "greater than" sign. + insertTextIndicator = "↪" # A right hooked arrow. + @title = @insertText if @insertText and request.isCustomSearch + # NOTE(philc): We're using these vimium-specific class names so we don't collide with the page's CSS. @html = - """ - <div class="vimiumReset vomnibarTopHalf"> - <span class="vimiumReset vomnibarSource #{insertTextClass}">#{insertTextIndicator}</span><span class="vimiumReset vomnibarSource">#{@type}</span> - <span class="vimiumReset vomnibarTitle">#{@highlightQueryTerms Utils.escapeHtml @title}</span> - </div> - <div class="vimiumReset vomnibarBottomHalf"> - <span class="vimiumReset vomnibarSource vomnibarNoInsertText">#{insertTextIndicator}</span><span class="vimiumReset vomnibarUrl">#{@highlightUrlTerms Utils.escapeHtml @shortenUrl()}</span> - #{relevancyHtml} - </div> - """ + if request.isCustomSearch + """ + <div class="vimiumReset vomnibarTopHalf"> + <span class="vimiumReset vomnibarSource #{insertTextClass}">#{insertTextIndicator}</span><span class="vimiumReset vomnibarSource">#{@type}</span> + <span class="vimiumReset vomnibarTitle">#{@highlightQueryTerms Utils.escapeHtml @title}</span> + #{relevancyHtml} + </div> + """ + else + """ + <div class="vimiumReset vomnibarTopHalf"> + <span class="vimiumReset vomnibarSource #{insertTextClass}">#{insertTextIndicator}</span><span class="vimiumReset vomnibarSource">#{@type}</span> + <span class="vimiumReset vomnibarTitle">#{@highlightQueryTerms Utils.escapeHtml @title}</span> + </div> + <div class="vimiumReset vomnibarBottomHalf"> + <span class="vimiumReset vomnibarSource vomnibarNoInsertText">#{insertTextIndicator}</span><span class="vimiumReset vomnibarUrl">#{@highlightUrlTerms Utils.escapeHtml @shortenUrl()}</span> + #{relevancyHtml} + </div> + """ # Use neat trick to snatch a domain (http://stackoverflow.com/a/8498668). getUrlRoot: (url) -> @@ -292,7 +303,7 @@ class DomainCompleter queryTerms: queryTerms type: "domain" url: domains[0]?[0] ? "" # This is the URL or an empty string, but not null. - relevancy: 1 + relevancy: 2.0 ].filter (s) -> 0 < s.url.length # Returns a list of domains of the form: [ [domain, relevancy], ... ] @@ -391,6 +402,7 @@ class TabCompleter title: tab.title relevancyFunction: @computeRelevancy tabId: tab.id + deDuplicate: false onComplete suggestions computeRelevancy: (suggestion) -> @@ -401,7 +413,6 @@ class TabCompleter class SearchEngineCompleter @debug: false - searchEngines: null previousSuggestions: null cancel: -> @@ -410,9 +421,9 @@ class SearchEngineCompleter # 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) => + SearchEngines.use (engines) => { queryTerms, query } = request - request.searchEngines = engines + extend request, searchEngines: engines, keywords: key for own key of engines keyword = queryTerms[0] # Note. For a keyword "w", we match "w search terms" and "w ", but not "w" on its own. if keyword and engines[keyword] and (1 < queryTerms.length or /\S\s/.test query) @@ -420,29 +431,11 @@ class SearchEngineCompleter queryTerms: queryTerms[1..] keyword: keyword engine: engines[keyword] + isCustomSearch: true 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 - + SearchEngines.refreshAndUse Settings.get("searchEngines"), (engines) -> # Let the front-end vomnibar know the search-engine keywords. It needs to know them so that, when the # query goes from "w" to "w ", the vomnibar can synchronously launch the next filter() request (which # avoids an ugly delay/flicker). @@ -452,90 +445,77 @@ class SearchEngineCompleter filter: (request, onComplete) -> { queryTerms, query, engine } = request + return onComplete [] unless engine - { custom, searchUrl, description } = - if engine - { keyword, searchUrl, description } = engine - extend request, { searchUrl, customSearchMode: true } - custom: true - searchUrl: searchUrl - description: description - else - custom: false - searchUrl: Settings.get "searchUrl" - description: "search" - - return onComplete [] unless custom or 0 < queryTerms.length + { keyword, searchUrl, description } = engine + extend request, searchUrl, customSearchMode: true - factor = Math.max 0.0, Math.min 1.0, Settings.get "omniSearchWeight" - haveCompletionEngine = (0.0 < factor or custom) and CompletionSearch.haveCompletionEngine searchUrl + factor = 0.5 + haveCompletionEngine = CompletionSearch.haveCompletionEngine searchUrl # This filter is applied to all of the suggestions from all of the completers, after they have been # aggregated by the MultiCompleter. filter = (suggestions) -> - 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 + suggestions.filter (suggestion) -> + # We only keep suggestions which either *were* generated by this search engine, or *could have + # been* generated by this search engine (and match the current query). + suggestion.isSearchSuggestion or suggestion.isCustomSearch or + ( + terms = Utils.extractQuery searchUrl, suggestion.url + terms and RankingUtils.matches queryTerms, terms + ) # If a previous suggestion still matches the query, then we keep it (even if the completion engine may not # return it for the current query). This allows the user to pick suggestions by typing fragments of their # text, without regard to whether the completion engine can complete the actual text of the query. previousSuggestions = - 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, highlightTerms: true, queryTerms: queryTerms - suggestion.relevancy = null - suggestion + if queryTerms.length == 0 + [] + else + for url, suggestion of @previousSuggestions + continue unless RankingUtils.matches queryTerms, suggestion.title + # Reset various fields, they may not be correct wrt. the current query. + extend suggestion, relevancy: null, html: null, queryTerms: queryTerms + suggestion.relevancy = null + suggestion primarySuggestion = new Suggestion queryTerms: queryTerms type: description url: Utils.createSearchUrl queryTerms, searchUrl title: queryTerms.join " " - relevancy: 1 - autoSelect: custom - highlightTerms: not haveCompletionEngine + relevancy: 2.0 + autoSelect: true + highlightTerms: false isSearchSuggestion: true + isPrimarySuggestion: 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 + return onComplete [ primarySuggestion ], { filter } if queryTerms.length == 0 + + mkSuggestion = do => + count = 0 + (suggestion) => + url = Utils.createSearchUrl suggestion, searchUrl + @previousSuggestions[url] = new Suggestion + queryTerms: queryTerms + type: description + url: url + title: suggestion + insertText: suggestion + highlightTerms: false + highlightTermsExcludeUrl: true + isCustomSearch: true + relevancy: if ++count == 1 then 1.0 else null + relevancyFunction: @computeRelevancy cachedSuggestions = if haveCompletionEngine then CompletionSearch.complete searchUrl, queryTerms else null suggestions = previousSuggestions - suggestions.push primarySuggestion if custom - suggestions.push cachedSuggestions.map(mkSuggestion)... if custom and cachedSuggestions? + suggestions.push primarySuggestion if queryTerms.length == 0 or cachedSuggestions? or not haveCompletionEngine - # There is no prospect of adding further completions. + # There is no prospect of adding further completions, so we're done. suggestions.push cachedSuggestions.map(mkSuggestion)... if cachedSuggestions? onComplete suggestions, { filter, continuation: null } else @@ -543,20 +523,7 @@ class SearchEngineCompleter # 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 [] - + continuation: (onComplete) => CompletionSearch.complete searchUrl, queryTerms, (suggestions = []) => console.log "fetched suggestions:", suggestions.length, query if SearchEngineCompleter.debug onComplete suggestions.map mkSuggestion @@ -567,7 +534,7 @@ class SearchEngineCompleter # 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 + 0.7 * RankingUtils.wordRelevancy queryTerms, title, title postProcessSuggestions: (request, suggestions) -> return unless request.searchEngines @@ -582,9 +549,6 @@ class SearchEngineCompleter # 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 @@ -639,7 +603,7 @@ class MultiCompleter if shouldRunContinuations jobs = new JobRunner continuations.map (continuation) -> (callback) -> - continuation suggestions, (newSuggestions) -> + continuation (newSuggestions) -> suggestions.push newSuggestions... callback() @@ -664,7 +628,7 @@ class MultiCompleter suggestions = for suggestion in suggestions url = suggestion.shortenUrl() - continue if seenUrls[url] + continue if suggestion.deDuplicate and seenUrls[url] break if count++ == @maxResults seenUrls[url] = suggestion @@ -672,7 +636,7 @@ class MultiCompleter completer.postProcessSuggestions? request, suggestions for completer in @completers # Generate HTML for the remaining suggestions and return them. - suggestion.generateHtml() for suggestion in suggestions + suggestion.generateHtml request for suggestion in suggestions suggestions # Utilities which help us compute a relevancy score for a given item. diff --git a/background_scripts/completion_engines.coffee b/background_scripts/completion_engines.coffee index 189f66f3..f15e6db4 100644 --- a/background_scripts/completion_engines.coffee +++ b/background_scripts/completion_engines.coffee @@ -21,12 +21,11 @@ # A base class for common regexp-based matching engines. class RegexpEngine - constructor: (@regexps) -> + constructor: (args...) -> @regexps = args.map (regexp) -> new RegExp regexp match: (searchUrl) -> Utils.matchesAnyRegexp @regexps, searchUrl # Several Google completion engines package XML responses in this way. class GoogleXMLRegexpEngine extends RegexpEngine - doNotCache: false # true (disbaled, experimental) parse: (xhr) -> for suggestion in xhr.responseXML.getElementsByTagName "suggestion" continue unless suggestion = suggestion.getAttribute "data" @@ -34,31 +33,47 @@ class GoogleXMLRegexpEngine extends RegexpEngine 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. - ] + constructor: (regexps = null) -> + super regexps ? "^https?://[a-z]+\.google\.(com|ie|co\.uk|ca|com\.au)/" getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "http://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=%s" +# A wrapper class for Google completions. This adds prefix terms to the query, and strips those terms from +# the resulting suggestions. For example, for Google Maps, we add "map of" as a prefix, then strip "map of" +# from the resulting suggestions. +class GoogleWithPrefix + constructor: (prefix, args...) -> + @engine = new Google args... + @prefix = "#{prefix.trim()} " + @queryTerms = @prefix.split /\s+/ + match: (args...) -> @engine.match args... + getUrl: (queryTerms) -> @engine.getUrl [ @queryTerms..., queryTerms... ] + parse: (xhr) -> + @engine.parse(xhr) + .filter (suggestion) => suggestion.startsWith @prefix + .map (suggestion) => suggestion[@prefix.length..].ltrim() + +# For Google Maps, we add the prefix "map of" to the query, and send it to Google's general search engine, +# then strip "map of" from the resulting suggestions. +class GoogleMaps extends GoogleWithPrefix + # Example search URL: https://www.google.com/maps?q=%s + constructor: -> super "map of", "https?://[a-z]+\.google\.(com|ie|co\.uk|ca|com\.au)/maps" + class Youtube extends GoogleXMLRegexpEngine # Example search URL: http://www.youtube.com/results?search_query=%s constructor: -> - super [ new RegExp "^https?://[a-z]+\.youtube\.com/results" ] + super "^https?://[a-z]+\.youtube\.com/results" getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "http://suggestqueries.google.com/complete/search?client=youtube&ds=yt&xml=t&q=%s" class Wikipedia extends RegexpEngine - 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/" ] + super "^https?://[a-z]+\.wikipedia\.org/" getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, @@ -67,28 +82,15 @@ class Wikipedia extends RegexpEngine 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" ] + constructor: -> super "^https?://www\.bing\.com/search" getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "http://api.bing.com/osjson.aspx?query=%s" parse: (xhr) -> JSON.parse(xhr.responseText)[1] class Amazon extends RegexpEngine # Example search URL: http://www.amazon.com/s/?field-keywords=%s - constructor: -> super [ new RegExp "^https?://www\.amazon\.(com|co.uk|ca|com.au)/s/" ] + constructor: -> super "^https?://www\.amazon\.(com|co.uk|ca|com.au)/s/" getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "https://completion.amazon.com/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=1&q=%s" @@ -96,15 +98,14 @@ class Amazon extends RegexpEngine class DuckDuckGo extends RegexpEngine # Example search URL: https://duckduckgo.com/?q=%s - constructor: -> super [ new RegExp "^https?://([a-z]+\.)?duckduckgo\.com/" ] - getUrl: (queryTerms) -> + constructor: -> super "^https?://([a-z]+\.)?duckduckgo\.com/" getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "https://duckduckgo.com/ac/?q=%s" parse: (xhr) -> suggestion.phrase for suggestion in JSON.parse xhr.responseText class Webster extends RegexpEngine # Example search URL: http://www.merriam-webster.com/dictionary/%s - constructor: -> super [ new RegExp "^https?://www.merriam-webster.com/dictionary/" ] + constructor: -> super "^https?://www.merriam-webster.com/dictionary/" getUrl: (queryTerms) -> Utils.createSearchUrl queryTerms, "http://www.merriam-webster.com/autocomplete?query=%s" parse: (xhr) -> JSON.parse(xhr.responseText).suggestions @@ -120,6 +121,7 @@ class DummyCompletionEngine # Note: Order matters here. CompletionEngines = [ Youtube + GoogleMaps Google DuckDuckGo Wikipedia diff --git a/background_scripts/exclusions.coffee b/background_scripts/exclusions.coffee index 5ec76e2a..21342d61 100644 --- a/background_scripts/exclusions.coffee +++ b/background_scripts/exclusions.coffee @@ -73,3 +73,7 @@ if not Settings.has("exclusionRules") and Settings.has("excludedUrls") # We'll keep a backup of the "excludedUrls" setting, just in case. Settings.set("excludedUrlsBackup", Settings.get("excludedUrls")) if not Settings.has("excludedUrlsBackup") Settings.clear("excludedUrls") + +# Register postUpdateHook for exclusionRules setting. +Settings.postUpdateHooks["exclusionRules"] = (value) -> + Exclusions.postUpdateHook value diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index 6ee0e8e7..99a5672b 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -268,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) @@ -345,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. @@ -556,13 +558,13 @@ checkKeyQueue = (keysToCheck, tabId, frameId) -> if runCommand if not registryEntry.isBackgroundCommand - chrome.tabs.sendMessage(tabId, - name: "executePageCommand", - command: registryEntry.command, - frameId: frameId, - count: count, - passCountToFunction: registryEntry.passCountToFunction, - completionKeys: generateCompletionKeys("")) + chrome.tabs.sendMessage tabId, + name: "executePageCommand" + command: registryEntry.command + frameId: frameId + count: count + completionKeys: generateCompletionKeys "" + registryEntry: registryEntry refreshedCompletionKeys = true else if registryEntry.passCountToFunction @@ -742,5 +744,5 @@ chrome.windows.getAll { populate: true }, (windows) -> chrome.tabs.sendMessage(tab.id, { name: "getScrollPosition" }, createScrollPositionHandler()) # Start pulling changes from synchronized storage. -Sync.init() +Settings.init() showUpgradeMessage() diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee deleted file mode 100644 index 269b4a2c..00000000 --- a/background_scripts/settings.coffee +++ /dev/null @@ -1,127 +0,0 @@ -# -# Used by all parts of Vimium to manipulate localStorage. -# - -root = exports ? window -root.Settings = Settings = - get: (key) -> - if (key of localStorage) then JSON.parse(localStorage[key]) else @defaults[key] - - set: (key, value) -> - # Don't store the value if it is equal to the default, so we can change the defaults in the future - if (value == @defaults[key]) - @clear(key) - else - jsonValue = JSON.stringify value - localStorage[key] = jsonValue - Sync.set key, jsonValue - - clear: (key) -> - if @has key - delete localStorage[key] - Sync.clear key - - has: (key) -> key of localStorage - - # For settings which require action when their value changes, add hooks here called from - # options/options.coffee (when the options page is saved), and from background_scripts/sync.coffee (when an - # update propagates from chrome.storage.sync). - postUpdateHooks: - keyMappings: (value) -> - root.Commands.clearKeyMappingsAndSetDefaults() - root.Commands.parseCustomKeyMappings value - root.refreshCompletionKeysAfterMappingSave() - - exclusionRules: (value) -> - root.Exclusions.postUpdateHook value - - # postUpdateHooks convenience wrapper - performPostUpdateHook: (key, value) -> - @postUpdateHooks[key] value if @postUpdateHooks[key] - - # options.coffee and options.html only handle booleans and strings; therefore all defaults must be booleans - # or strings - defaults: - scrollStepSize: 60 - omniSearchWeight: 0.4 - smoothScroll: true - keyMappings: "# Insert your preferred key mappings here." - linkHintCharacters: "sadfjklewcmpgh" - linkHintNumbers: "0123456789" - filterLinkHints: false - hideHud: false - userDefinedLinkHintCss: - """ - div > .vimiumHintMarker { - /* linkhint boxes */ - background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785), - color-stop(100%,#FFC542)); - border: 1px solid #E3BE23; - } - - div > .vimiumHintMarker span { - /* linkhint text */ - color: black; - font-weight: bold; - font-size: 12px; - } - - div > .vimiumHintMarker > .matchingCharacter { - } - """ - # Default exclusion rules. - exclusionRules: - [ - # Disable Vimium on Gmail. - { pattern: "http*://mail.google.com/*", passKeys: "" } - ] - - # NOTE: If a page contains both a single angle-bracket link and a double angle-bracket link, then in - # most cases the single bracket link will be "prev/next page" and the double bracket link will be - # "first/last page", so we put the single bracket first in the pattern string so that it gets searched - # for first. - - # "\bprev\b,\bprevious\b,\bback\b,<,←,«,≪,<<" - previousPatterns: "prev,previous,back,<,\u2190,\xab,\u226a,<<" - # "\bnext\b,\bmore\b,>,→,»,≫,>>" - nextPatterns: "next,more,>,\u2192,\xbb,\u226b,>>" - # default/fall back search engine - searchUrl: "https://www.google.com/search?q=" - # put in an example search engine - searchEngines: [ - "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s Wikipedia" - "" - "# More examples." - "#" - "# (Vimium has built-in completion for these.)" - "#" - "# g: http://www.google.com/search?q=%s Google" - "# l: http://www.google.com/search?q=%s&btnI I'm feeling lucky..." - "# y: http://www.youtube.com/results?search_query=%s Youtube" - "# b: https://www.bing.com/search?q=%s Bing" - "# d: https://duckduckgo.com/?q=%s DuckDuckGo" - "# az: http://www.amazon.com/s/?field-keywords=%s Amazon" - "#" - "# Another example (for Vimium does not have completion)." - "#" - "# m: https://www.google.com/maps/search/%s Google Maps" - ].join "\n" - newTabUrl: "chrome://newtab" - grabBackFocus: false - - settingsVersion: Utils.getCurrentVersion() - - -# We use settingsVersion to coordinate any necessary schema changes. -if Utils.compareVersions("1.42", Settings.get("settingsVersion")) != -1 - Settings.set("scrollStepSize", parseFloat Settings.get("scrollStepSize")) -Settings.set("settingsVersion", Utils.getCurrentVersion()) - -# Migration (after 1.49, 2015/2/1). -# Legacy setting: findModeRawQuery (a string). -# New setting: findModeRawQueryList (a list of strings), now stored in chrome.storage.local (not localStorage). -chrome.storage.local.get "findModeRawQueryList", (items) -> - unless chrome.runtime.lastError or items.findModeRawQueryList - rawQuery = Settings.get "findModeRawQuery" - chrome.storage.local.set findModeRawQueryList: (if rawQuery then [ rawQuery ] else []) - diff --git a/background_scripts/sync.coffee b/background_scripts/sync.coffee deleted file mode 100644 index d0d501d3..00000000 --- a/background_scripts/sync.coffee +++ /dev/null @@ -1,74 +0,0 @@ -# -# * Sync.set() and Sync.clear() propagate local changes to chrome.storage.sync. -# * Sync.handleStorageUpdate() listens for changes to chrome.storage.sync and propagates those -# changes to localStorage and into vimium's internal state. -# * Sync.fetchAsync() polls chrome.storage.sync at startup, similarly propagating -# changes to localStorage and into vimium's internal state. -# -# Changes are propagated into vimium's state using the same mechanism -# (Settings.performPostUpdateHook) that is used when options are changed on -# the options page. -# -# The effect is best-effort synchronization of vimium options/settings between -# chrome/vimium instances. -# -# NOTE: -# Values handled within this module are ALWAYS already JSON.stringifed, so -# they're always non-empty strings. -# - -root = exports ? window -root.Sync = Sync = - - storage: chrome.storage.sync - doNotSync: ["settingsVersion", "previousVersion"] - - # This is called in main.coffee. - init: -> - chrome.storage.onChanged.addListener (changes, area) -> Sync.handleStorageUpdate changes, area - @fetchAsync() - - # Asynchronous fetch from synced storage, called only at startup. - fetchAsync: -> - @storage.get null, (items) => - unless chrome.runtime.lastError - for own key, value of items - @storeAndPropagate key, value - - # Asynchronous message from synced storage. - handleStorageUpdate: (changes, area) -> - for own key, change of changes - @storeAndPropagate key, change?.newValue - - # Only ever called from asynchronous synced-storage callbacks (fetchAsync and handleStorageUpdate). - storeAndPropagate: (key, value) -> - return unless key of Settings.defaults - return if not @shouldSyncKey key - return if value and key of localStorage and localStorage[key] is value - defaultValue = Settings.defaults[key] - defaultValueJSON = JSON.stringify(defaultValue) - - if value and value != defaultValueJSON - # Key/value has been changed to non-default value at remote instance. - localStorage[key] = value - Settings.performPostUpdateHook key, JSON.parse(value) - else - # Key has been reset to default value at remote instance. - if key of localStorage - delete localStorage[key] - Settings.performPostUpdateHook key, defaultValue - - # Only called synchronously from within vimium, never on a callback. - # No need to propagate updates to the rest of vimium, that's already been done. - set: (key, value) -> - if @shouldSyncKey key - setting = {}; setting[key] = value - @storage.set setting - - # Only called synchronously from within vimium, never on a callback. - clear: (key) -> - @storage.remove key if @shouldSyncKey key - - # Should we synchronize this key? - shouldSyncKey: (key) -> key not in @doNotSync - diff --git a/content_scripts/hud.coffee b/content_scripts/hud.coffee index e07d0713..f38d6b45 100644 --- a/content_scripts/hud.coffee +++ b/content_scripts/hud.coffee @@ -57,6 +57,12 @@ class Tween constructor: (@cssSelector, insertionPoint = document.documentElement) -> @styleElement = document.createElement "style" + + unless @styleElement.style + # We're in an XML document, so we shouldn't inject any elements. See the comment in UIComponent. + Tween::fade = Tween::stop = Tween::updateStyle = -> + return + @styleElement.type = "text/css" @styleElement.innerHTML = "" insertionPoint.appendChild @styleElement diff --git a/content_scripts/mode_visual_edit.coffee b/content_scripts/mode_visual_edit.coffee index 326ee8c9..8d1d96cc 100644 --- a/content_scripts/mode_visual_edit.coffee +++ b/content_scripts/mode_visual_edit.coffee @@ -143,7 +143,12 @@ class Movement extends CountPrefix # Test whether the character following the focus is a word character (and leave the selection unchanged). nextCharacterIsWordCharacter: do -> - regexp = /[A-Za-z0-9_]/; -> regexp.test @getNextForwardCharacter() + regexp = null + -> + # This regexp matches "word" characters (apparently in any language). + # From http://stackoverflow.com/questions/150033/regular-expression-to-match-non-english-characters + regexp || = /[_0-9\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]/ + regexp.test @getNextForwardCharacter() # Run a movement. This is the core movement method, all movements happen here. For convenience, the # following three argument forms are supported: diff --git a/content_scripts/ui_component.coffee b/content_scripts/ui_component.coffee index e7cd3f82..e4cfc293 100644 --- a/content_scripts/ui_component.coffee +++ b/content_scripts/ui_component.coffee @@ -7,6 +7,18 @@ class UIComponent constructor: (iframeUrl, className, @handleMessage) -> styleSheet = document.createElement "style" + + unless styleSheet.style + # If this is an XML document, nothing we do here works: + # * <style> elements show their contents inline, + # * <iframe> elements don't load any content, + # * document.createElement generates elements that have style == null and ignore CSS. + # If this is the case we don't want to pollute the DOM to no or negative effect. So we bail + # immediately, and disable all externally-called methods. + @postMessage = @activate = @show = @hide = -> + console.log "This vimium feature is disabled because it is incompatible with this page." + return + styleSheet.type = "text/css" # Default to everything hidden while the stylesheet loads. styleSheet.innerHTML = "@import url(\"#{chrome.runtime.getURL("content_scripts/vimium.css")}\");" @@ -15,19 +27,33 @@ class UIComponent extend @iframeElement, className: className seamless: "seamless" - src: chrome.runtime.getURL iframeUrl - @iframeElement.addEventListener "load", => @openPort() 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 - document.documentElement.appendChild shadowWrapper @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. @@ -35,40 +61,31 @@ 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] - - # Posts a message; returns true if the message was sent, false otherwise. - postMessage: (message) -> - # We use "?" here because the iframe port is initialized asynchronously, and may not yet be ready. - @iframePort?.postMessage message - @iframePort? + # 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) -> - if @postMessage @options + @postMessage @options, => @show() unless @showing @iframeElement.focus() show: (message) -> - @postMessage message if 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 + @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 diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css index 647c8025..b4bce776 100644 --- a/content_scripts/vimium.css +++ b/content_scripts/vimium.css @@ -208,13 +208,18 @@ div#vimiumHelpDialog a { text-decoration: underline; } -div#vimiumHelpDialog .optionsPage { +div#vimiumHelpDialog .wikiPage, div#vimiumHelpDialog .optionsPage { position: absolute; display: block; font-size: 11px; line-height: 130%; - right: 60px; - top: 8px; + top: 6px; +} +div#vimiumHelpDialog .optionsPage { + right: 40px; +} +div#vimiumHelpDialog .wikiPage { + right: 83px; } div#vimiumHelpDialog a.closeButton:hover { color:black; diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 7e0416aa..c8c83029 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -62,6 +62,7 @@ settings = helpDialog_showAdvancedCommands: null smoothScroll: null grabBackFocus: null + searchEngines: null init: -> @port = chrome.runtime.connect name: "settings" @@ -307,14 +308,14 @@ executePageCommand = (request) -> if DomUtils.isTopFrame() # We pass the frameId from request. That's the frame which originated the request, so that's the frame # which should receive the focus when the vomnibar closes. - Utils.invokeCommandString request.command, [ request.frameId ] + Utils.invokeCommandString request.command, [ request.frameId, request.registryEntry ] refreshCompletionKeys request return # All other commands are handled in their frame (but only if Vimium is enabled). return unless frameId == request.frameId and isEnabledForUrl - if (request.passCountToFunction) + if request.registryEntry.passCountToFunction Utils.invokeCommandString(request.command, [request.count]) else Utils.invokeCommandString(request.command) for i in [0...request.count] @@ -718,21 +719,16 @@ updateFindModeQuery = -> # character. here we grep for the relevant escape sequences. findModeQuery.isRegex = settings.get 'regexFindMode' hasNoIgnoreCaseFlag = false - findModeQuery.parsedQuery = findModeQuery.rawQuery.replace /\\./g, (match) -> - switch (match) - when "\\r" + findModeQuery.parsedQuery = findModeQuery.rawQuery.replace /(\\{1,2})([rRI]?)/g, (match, slashes, flag) -> + return match if flag == "" or slashes.length != 1 + switch (flag) + when "r" findModeQuery.isRegex = true - return "" - when "\\R" + when "R" findModeQuery.isRegex = false - return "" - when "\\I" + when "I" hasNoIgnoreCaseFlag = true - return "" - when "\\\\" - return "\\" - else - return match + "" # default to 'smartcase' mode, unless noIgnoreCase is explicitly specified findModeQuery.ignoreCase = !hasNoIgnoreCaseFlag && !Utils.hasUpperCase(findModeQuery.parsedQuery) @@ -1125,6 +1121,8 @@ window.showHelpDialog = (html, fid) -> chrome.runtime.sendMessage({handler: "openOptionsPageInNewTab"}) false) + # Simulating a click on the help dialog makes it the active element for scrolling. + DomUtils.simulateClick document.getElementById "vimiumHelpDialog" hideHelpDialog = (clickEvent) -> isShowingHelpDialog = false diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee index 2529c077..4bd8e8fd 100644 --- a/content_scripts/vomnibar.coffee +++ b/content_scripts/vomnibar.coffee @@ -4,14 +4,36 @@ Vomnibar = vomnibarUI: null + # Parse any additional options from the command's registry entry. Currently, this only includes a flag of + # the form "keyword=X", for direct activation of a custom search engine. + parseRegistryEntry: (registryEntry = { options: [] }, callback = null) -> + options = {} + searchEngines = settings.get("searchEngines") ? "" + SearchEngines.refreshAndUse searchEngines, (engines) -> + for option in registryEntry.options + [ key, value ] = option.split "=" + switch key + when "keyword" + if value? and engines[value]? + options.keyword = value + else + console.log "Vimium configuration error: no such custom search engine: #{option}." + else + console.log "Vimium configuration error: unused flag: #{option}." + + callback? options + # sourceFrameId here (and below) is the ID of the frame from which this request originates, which may be different # from the current frame. - activate: (sourceFrameId) -> @open sourceFrameId, {completer:"omni"} - activateInNewTab: (sourceFrameId) -> @open sourceFrameId, { - completer: "omni" - selectFirst: false - newTab: true - } + + activate: (sourceFrameId, registryEntry) -> + @parseRegistryEntry registryEntry, (options) => + @open sourceFrameId, extend options, completer:"omni" + + activateInNewTab: (sourceFrameId, registryEntry) -> + @parseRegistryEntry registryEntry, (options) => + @open sourceFrameId, extend options, completer:"omni", newTab: true + activateTabSelection: (sourceFrameId) -> @open sourceFrameId, { completer: "tabs" selectFirst: true diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index 30b9f68c..7c47179c 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -62,17 +62,29 @@ DomUtils = # 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 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 + # 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' && - computedStyle.getPropertyValue('position') != 'absolute') + 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 diff --git a/lib/settings.coffee b/lib/settings.coffee new file mode 100644 index 00000000..dd667dbd --- /dev/null +++ b/lib/settings.coffee @@ -0,0 +1,202 @@ +# +# * Sync.set() and Sync.clear() propagate local changes to chrome.storage.sync. +# * Sync.handleStorageUpdate() listens for changes to chrome.storage.sync and propagates those +# changes to localStorage and into vimium's internal state. +# * Sync.fetchAsync() polls chrome.storage.sync at startup, similarly propagating +# changes to localStorage and into vimium's internal state. +# +# The effect is best-effort synchronization of vimium options/settings between +# chrome/vimium instances. +# +# NOTE: +# Values handled within this module are ALWAYS already JSON.stringifed, so +# they're always non-empty strings. +# + +root = exports ? window +Sync = + + storage: chrome.storage.sync + doNotSync: ["settingsVersion", "previousVersion"] + + # This is called in main.coffee. + init: -> + chrome.storage.onChanged.addListener (changes, area) -> Sync.handleStorageUpdate changes, area + @fetchAsync() + + # Asynchronous fetch from synced storage, called only at startup. + fetchAsync: -> + @storage.get null, (items) => + unless chrome.runtime.lastError + for own key, value of items + Settings.storeAndPropagate key, value if @shouldSyncKey key + + # Asynchronous message from synced storage. + handleStorageUpdate: (changes, area) -> + for own key, change of changes + Settings.storeAndPropagate key, change?.newValue if @shouldSyncKey key + + # Only called synchronously from within vimium, never on a callback. + # No need to propagate updates to the rest of vimium, that's already been done. + set: (key, value) -> + if @shouldSyncKey key + setting = {}; setting[key] = value + @storage.set setting + + # Only called synchronously from within vimium, never on a callback. + clear: (key) -> + @storage.remove key if @shouldSyncKey key + + # Should we synchronize this key? + shouldSyncKey: (key) -> key not in @doNotSync + +# +# Used by all parts of Vimium to manipulate localStorage. +# + +# Select the object to use as the cache for settings. +if Utils.isExtensionPage() + if Utils.isBackgroundPage() + settingsCache = localStorage + else + settingsCache = extend {}, localStorage # Make a copy of the cached settings from localStorage +else + settingsCache = {} + +root.Settings = Settings = + cache: settingsCache + init: -> Sync.init() + get: (key) -> + if (key of @cache) then JSON.parse(@cache[key]) else @defaults[key] + + set: (key, value) -> + # Don't store the value if it is equal to the default, so we can change the defaults in the future + if (value == @defaults[key]) + @clear(key) + else + jsonValue = JSON.stringify value + @cache[key] = jsonValue + Sync.set key, jsonValue + + clear: (key) -> + if @has key + delete @cache[key] + Sync.clear key + + has: (key) -> key of @cache + + # For settings which require action when their value changes, add hooks to this object, to be called from + # options/options.coffee (when the options page is saved), and by Settings.storeAndPropagate (when an + # update propagates from chrome.storage.sync). + postUpdateHooks: {} + + # postUpdateHooks convenience wrapper + performPostUpdateHook: (key, value) -> + @postUpdateHooks[key]? value + + # Only ever called from asynchronous synced-storage callbacks (fetchAsync and handleStorageUpdate). + storeAndPropagate: (key, value) -> + return unless key of @defaults + return if value and key of @cache and @cache[key] is value + defaultValue = @defaults[key] + defaultValueJSON = JSON.stringify(defaultValue) + + if value and value != defaultValueJSON + # Key/value has been changed to non-default value at remote instance. + @cache[key] = value + @performPostUpdateHook key, JSON.parse(value) + else + # Key has been reset to default value at remote instance. + if key of @cache + delete @cache[key] + @performPostUpdateHook key, defaultValue + + # options.coffee and options.html only handle booleans and strings; therefore all defaults must be booleans + # or strings + defaults: + scrollStepSize: 60 + smoothScroll: true + keyMappings: "# Insert your preferred key mappings here." + linkHintCharacters: "sadfjklewcmpgh" + linkHintNumbers: "0123456789" + filterLinkHints: false + hideHud: false + userDefinedLinkHintCss: + """ + div > .vimiumHintMarker { + /* linkhint boxes */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785), + color-stop(100%,#FFC542)); + border: 1px solid #E3BE23; + } + + div > .vimiumHintMarker span { + /* linkhint text */ + color: black; + font-weight: bold; + font-size: 12px; + } + + div > .vimiumHintMarker > .matchingCharacter { + } + """ + # Default exclusion rules. + exclusionRules: + [ + # Disable Vimium on Gmail. + { pattern: "http*://mail.google.com/*", passKeys: "" } + ] + + # NOTE: If a page contains both a single angle-bracket link and a double angle-bracket link, then in + # most cases the single bracket link will be "prev/next page" and the double bracket link will be + # "first/last page", so we put the single bracket first in the pattern string so that it gets searched + # for first. + + # "\bprev\b,\bprevious\b,\bback\b,<,←,«,≪,<<" + previousPatterns: "prev,previous,back,<,\u2190,\xab,\u226a,<<" + # "\bnext\b,\bmore\b,>,→,»,≫,>>" + nextPatterns: "next,more,>,\u2192,\xbb,\u226b,>>" + # default/fall back search engine + searchUrl: "https://www.google.com/search?q=" + # put in an example search engine + searchEngines: [ + "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s Wikipedia" + "" + "# More examples." + "#" + "# (Vimium has built-in completion for these.)" + "#" + "# g: http://www.google.com/search?q=%s Google" + "# l: http://www.google.com/search?q=%s&btnI I'm feeling lucky..." + "# y: http://www.youtube.com/results?search_query=%s Youtube" + "# b: https://www.bing.com/search?q=%s Bing" + "# d: https://duckduckgo.com/?q=%s DuckDuckGo" + "# az: http://www.amazon.com/s/?field-keywords=%s Amazon" + "#" + "# Another example (for Vimium does not have completion)." + "#" + "# m: https://www.google.com/maps/search/%s Google Maps" + ].join "\n" + newTabUrl: "chrome://newtab" + grabBackFocus: false + + settingsVersion: Utils.getCurrentVersion() + +# Export Sync via Settings for tests. +root.Settings.Sync = Sync + +# Perform migration from old settings versions, if this is the background page. +if Utils.isBackgroundPage() + + # We use settingsVersion to coordinate any necessary schema changes. + if Utils.compareVersions("1.42", Settings.get("settingsVersion")) != -1 + Settings.set("scrollStepSize", parseFloat Settings.get("scrollStepSize")) + Settings.set("settingsVersion", Utils.getCurrentVersion()) + + # Migration (after 1.49, 2015/2/1). + # Legacy setting: findModeRawQuery (a string). + # New setting: findModeRawQueryList (a list of strings), now stored in chrome.storage.local (not localStorage). + chrome.storage.local.get "findModeRawQueryList", (items) -> + unless chrome.runtime.lastError or items.findModeRawQueryList + rawQuery = Settings.get "findModeRawQuery" + chrome.storage.local.set findModeRawQueryList: (if rawQuery then [ rawQuery ] else []) diff --git a/lib/utils.coffee b/lib/utils.coffee index 65e26b7a..93045f32 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -2,6 +2,13 @@ Utils = getCurrentVersion: -> chrome.runtime.getManifest().version + # Returns true whenever the current page is from the extension's origin (and thus can access the + # extension's localStorage). + isExtensionPage: -> document.location?.origin + "/" == chrome.extension.getURL "" + + # Returns true whenever the current page is the extension's background page. + isBackgroundPage: -> @isExtensionPage() and chrome.extension.getBackgroundPage() == window + # Takes a dot-notation object string and call the function # that it points to with the correct value for 'this'. invokeCommandString: (str, argArray) -> @@ -228,6 +235,38 @@ Utils = # Like Nodejs's nextTick. nextTick: (func) -> @setTimeout 0, func +# Utility for parsing and using the custom search-engine configuration. We re-use the previous parse if the +# search-engine configuration is unchanged. +SearchEngines = + previousSearchEngines: null + searchEngines: null + + refresh: (searchEngines) -> + unless @previousSearchEngines? and searchEngines == @previousSearchEngines + @previousSearchEngines = searchEngines + @searchEngines = new AsyncDataFetcher (callback) -> + engines = {} + for line in searchEngines.split "\n" + line = line.trim() + continue if /^[#"]/.test line + tokens = line.split /\s+/ + continue unless 2 <= tokens.length + keyword = tokens[0].split(":")[0] + searchUrl = tokens[1] + description = tokens[2..].join(" ") || "search (#{keyword})" + continue unless Utils.hasFullUrlPrefix searchUrl + engines[keyword] = { keyword, searchUrl, description } + + callback engines + + # Use the parsed search-engine configuration, possibly asynchronously. + use: (callback) -> + @searchEngines.use callback + + # Both set (refresh) the search-engine configuration and use it at the same time. + refreshAndUse: (searchEngines, callback) -> + @refresh searchEngines + @use callback # This creates a new function out of an existing function, where the new function takes fewer arguments. This # allows us to pass around functions instead of functions + a partial list of arguments. @@ -325,6 +364,7 @@ class JobRunner root = exports ? window root.Utils = Utils +root.SearchEngines = SearchEngines root.SimpleCache = SimpleCache root.AsyncDataFetcher = AsyncDataFetcher root.JobRunner = JobRunner diff --git a/manifest.json b/manifest.json index fe5c69ca..f0c51117 100644 --- a/manifest.json +++ b/manifest.json @@ -9,10 +9,9 @@ "background": { "scripts": [ "lib/utils.js", + "lib/settings.js", "background_scripts/commands.js", "lib/clipboard.js", - "background_scripts/sync.js", - "background_scripts/settings.js", "background_scripts/exclusions.js", "background_scripts/completion_engines.js", "background_scripts/completion_search.js", diff --git a/pages/help_dialog.html b/pages/help_dialog.html index 77c3e2bf..5c09c0ab 100644 --- a/pages/help_dialog.html +++ b/pages/help_dialog.html @@ -7,6 +7,7 @@ page with the up-to-date key bindings when the dialog is shown. --> <div id="vimiumHelpDialog" class="vimiumReset"> <a class="vimiumReset optionsPage" href="#">Options</a> + <a class="vimiumReset wikiPage" href="https://github.com/philc/vimium/wiki" target="_blank">Wiki</a> <a class="vimiumReset closeButton" href="#">×</a> <div id="vimiumTitle" class="vimiumReset"><span class="vimiumReset" style="color:#2f508e">Vim</span>ium {{title}}</div> <div class="vimiumReset vimiumColumn"> @@ -20,6 +21,8 @@ <div class="vimiumReset vimiumColumn"> <table class="vimiumReset" > <tbody class="vimiumReset"> + <tr class="vimiumReset" ><td class="vimiumReset" ></td><td class="vimiumReset" ></td><td class="vimiumReset vimiumHelpSectionTitle">Using the vomnibar</td></tr> + {{vomnibarCommands}} <tr class="vimiumReset" ><td class="vimiumReset" ></td><td class="vimiumReset" ></td><td class="vimiumReset vimiumHelpSectionTitle">Using find</td></tr> {{findCommands}} <tr class="vimiumReset" ><td class="vimiumReset" ></td><td class="vimiumReset" ></td><td class="vimiumReset vimiumHelpSectionTitle">Navigating history</td></tr> diff --git a/pages/options.coffee b/pages/options.coffee index 18ff226d..110f869c 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -1,7 +1,6 @@ $ = (id) -> document.getElementById id -bgUtils = chrome.extension.getBackgroundPage().Utils -bgSettings = chrome.extension.getBackgroundPage().Settings +Settings.init() bgExclusions = chrome.extension.getBackgroundPage().Exclusions # @@ -22,21 +21,20 @@ class Option # Fetch a setting from localStorage, remember the @previous value and populate the DOM element. # Return the fetched value. fetch: -> - @populateElement @previous = bgSettings.get @field + @populateElement @previous = Settings.get @field @previous # Write this option's new value back to localStorage, if necessary. save: -> value = @readValueFromElement() if not @areEqual value, @previous - bgSettings.set @field, @previous = value - bgSettings.performPostUpdateHook @field, value + Settings.set @field, @previous = value # Compare values; this is overridden by sub-classes. areEqual: (a,b) -> a == b restoreToDefault: -> - bgSettings.clear @field + Settings.clear @field @fetch() # Static method. diff --git a/pages/options.html b/pages/options.html index 75089d75..67e2b16d 100644 --- a/pages/options.html +++ b/pages/options.html @@ -3,6 +3,7 @@ <title>Vimium Options</title> <link rel="stylesheet" type="text/css" href="options.css"> <script src="content_script_loader.js"></script> + <script type="text/javascript" src="../lib/settings.js"></script> <script type="text/javascript" src="options.js"></script> </head> @@ -71,6 +72,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"> @@ -235,6 +239,8 @@ b: http://b.com/?q=%s description </tr> <!-- Vimium Labs --> + <!-- + Disabled. But we leave this code here as a template for the next time we need to introduce "Vimium Labs". <tr> <td colspan="2"><header>Vimium Labs</header></td> </tr> @@ -260,6 +266,7 @@ b: http://b.com/?q=%s description <input id="omniSearchWeight" type="number" min="0.0" max="1.0" step="0.05" />(0 to 1) </td> </tr> + --> </tbody> </table> </div> diff --git a/pages/popup.html b/pages/popup.html index c7e2fd6f..fdf116e5 100644 --- a/pages/popup.html +++ b/pages/popup.html @@ -48,6 +48,8 @@ } </style> + <script src="../lib/utils.js"></script> + <script src="../lib/settings.js"></script> <script src="options.js"></script> </head> <body> diff --git a/pages/vomnibar.coffee b/pages/vomnibar.coffee index fd7fd3cc..d5659fdc 100644 --- a/pages/vomnibar.coffee +++ b/pages/vomnibar.coffee @@ -17,6 +17,7 @@ Vomnibar = query: "" newTab: false selectFirst: false + keyword: null extend options, userOptions extend options, refreshInterval: if options.completer == "omni" then 150 else 0 @@ -28,6 +29,7 @@ Vomnibar = @vomnibarUI.setRefreshInterval options.refreshInterval @vomnibarUI.setForceNewTab options.newTab @vomnibarUI.setQuery options.query + @vomnibarUI.setKeyword options.keyword @vomnibarUI.update true hide: -> @vomnibarUI?.hide() @@ -40,6 +42,7 @@ class VomnibarUI @initDom() setQuery: (query) -> @input.value = query + setKeyword: (keyword) -> @customSearchMode = keyword setInitialSelectionValue: (@initialSelectionValue) -> setRefreshInterval: (@refreshInterval) -> setForceNewTab: (@forceNewTab) -> @@ -76,7 +79,7 @@ class VomnibarUI updateSelection: -> # 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? + if @lastReponse.isCustomSearch and not @customSearchMode? queryTerms = @input.value.trim().split /\s+/ @customSearchMode = queryTerms[0] @input.value = queryTerms[1..].join " " @@ -126,9 +129,12 @@ class VomnibarUI if (action == "dismiss") @hide() else if action in [ "tab", "down" ] - if @input.value.trim().length == 0 and action == "tab" and not @seenTabToOpenCompletionList - @seenTabToOpenCompletionList = true - @update true + 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 @@ -138,15 +144,23 @@ class VomnibarUI @selection = @completions.length - 1 if @selection < @initialSelectionValue @updateSelection() else if (action == "enter") - if @selection == -1 + isCustomSearchPrimarySuggestion = @completions[@selection]?.isPrimarySuggestion and @lastReponse.engine?.searchUrl? + if @selection == -1 or isCustomSearchPrimarySuggestion query = @input.value.trim() # <Enter> on an empty query is a no-op. return unless 0 < query.length + # First case (@selection == -1). # If the user types something and hits enter without selecting a completion from the list, then: # - If a search URL has been provided, then use it. This is custom search engine request. # - Otherwise, send the query to the background page, which will open it as a URL or create a # default search, as appropriate. - query = Utils.createSearchUrl query, @lastReponse.searchUrl if @lastReponse.searchUrl? + # + # Second case (isCustomSearchPrimarySuggestion). + # Alternatively, the selected completion could be the primary selection for a custom search engine. + # Because the the suggestions are updated asynchronously in omni mode, the user may have typed more + # text than that which is included in the URL associated with the primary suggestion. Therefore, to + # avoid a race condition, we construct the query from the actual contents of the input (query). + query = Utils.createSearchUrl query, @lastReponse.engine.searchUrl if isCustomSearchPrimarySuggestion @hide -> chrome.runtime.sendMessage handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" diff --git a/pages/vomnibar.css b/pages/vomnibar.css index b1ed0252..1b19daad 100644 --- a/pages/vomnibar.css +++ b/pages/vomnibar.css @@ -126,7 +126,6 @@ #vomnibar li em { font-style: italic; } #vomnibar li em .vomnibarMatch, #vomnibar li .vomnibarTitle .vomnibarMatch { color: #333; - text-decoration: underline; } #vomnibar li.vomnibarSelected { diff --git a/tests/dom_tests/dom_utils_test.coffee b/tests/dom_tests/dom_utils_test.coffee index e98dc958..ce8fa370 100644 --- a/tests/dom_tests/dom_utils_test.coffee +++ b/tests/dom_tests/dom_utils_test.coffee @@ -73,6 +73,15 @@ context "Check visibility", """ 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. document.getElementById("test-div").innerHTML = """ 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 380175f3..3eda6234 100644 --- a/tests/dom_tests/vomnibar_test.coffee +++ b/tests/dom_tests/vomnibar_test.coffee @@ -1,4 +1,5 @@ vomnibarFrame = null +SearchEngines.refresh "" context "Keep selection within bounds", diff --git a/tests/unit_tests/commands_test.coffee b/tests/unit_tests/commands_test.coffee index daaef016..e55dc0f2 100644 --- a/tests/unit_tests/commands_test.coffee +++ b/tests/unit_tests/commands_test.coffee @@ -1,5 +1,6 @@ require "./test_helper.js" extend global, require "./test_chrome_stubs.js" +global.Settings = {postUpdateHooks: {}} {Commands} = require "../../background_scripts/commands.js" context "Key mappings", diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index 88df0a43..4a0cf746 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -244,7 +244,7 @@ context "suggestions", url: "url" title: "title <span>" relevancyFunction: returns 1 - assert.isTrue suggestion.generateHtml().indexOf("title <span>") >= 0 + assert.isTrue suggestion.generateHtml({}).indexOf("title <span>") >= 0 should "highlight query words", -> suggestion = new Suggestion @@ -254,7 +254,7 @@ context "suggestions", title: "ninjawords" relevancyFunction: returns 1 expected = "<span class='vomnibarMatch'>ninj</span>a<span class='vomnibarMatch'>words</span>" - assert.isTrue suggestion.generateHtml().indexOf(expected) >= 0 + assert.isTrue suggestion.generateHtml({}).indexOf(expected) >= 0 should "highlight query words correctly when whey they overlap", -> suggestion = new Suggestion @@ -264,7 +264,7 @@ context "suggestions", title: "ninjawords" relevancyFunction: returns 1 expected = "<span class='vomnibarMatch'>ninjaword</span>s" - assert.isTrue suggestion.generateHtml().indexOf(expected) >= 0 + assert.isTrue suggestion.generateHtml({}).indexOf(expected) >= 0 should "shorten urls", -> suggestion = new Suggestion @@ -273,7 +273,7 @@ context "suggestions", url: "http://ninjawords.com" title: "ninjawords" relevancyFunction: returns 1 - assert.equal -1, suggestion.generateHtml().indexOf("http://ninjawords.com") + assert.equal -1, suggestion.generateHtml({}).indexOf("http://ninjawords.com") context "RankingUtils.wordRelevancy", should "score higher in shorter URLs", -> diff --git a/tests/unit_tests/exclusion_test.coffee b/tests/unit_tests/exclusion_test.coffee index b3ed7194..28c17a2f 100644 --- a/tests/unit_tests/exclusion_test.coffee +++ b/tests/unit_tests/exclusion_test.coffee @@ -14,9 +14,8 @@ root.Marks = extend(global, require "../../lib/utils.js") Utils.getCurrentVersion = -> '1.44' -extend(global,require "../../background_scripts/sync.js") -extend(global,require "../../background_scripts/settings.js") -Sync.init() +extend(global,require "../../lib/settings.js") +Settings.init() extend(global, require "../../background_scripts/exclusions.js") extend(global, require "../../background_scripts/commands.js") extend(global, require "../../background_scripts/main.js") diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee index 4cd20211..ded7b5f8 100644 --- a/tests/unit_tests/settings_test.coffee +++ b/tests/unit_tests/settings_test.coffee @@ -3,15 +3,18 @@ extend global, require "./test_chrome_stubs.js" extend(global, require "../../lib/utils.js") Utils.getCurrentVersion = -> '1.44' +Utils.isBackgroundPage = -> true +Utils.isExtensionPage = -> true global.localStorage = {} -extend(global,require "../../background_scripts/sync.js") -extend(global,require "../../background_scripts/settings.js") -Sync.init() +extend(global,require "../../lib/settings.js") context "settings", setup -> stub global, 'localStorage', {} + Settings.cache = global.localStorage # Point the settings cache to the new localStorage object. + Settings.postUpdateHooks = {} # Avoid running update hooks which include calls to outside of settings. + Settings.init() should "save settings in localStorage as JSONified strings", -> Settings.set 'dummy', "" @@ -39,24 +42,22 @@ context "settings", should "propagate non-default value via synced storage listener", -> Settings.set 'scrollStepSize', 20 assert.equal Settings.get('scrollStepSize'), 20 - Sync.handleStorageUpdate { scrollStepSize: { newValue: "40" } } + Settings.Sync.handleStorageUpdate { scrollStepSize: { newValue: "40" } } assert.equal Settings.get('scrollStepSize'), 40 should "propagate default value via synced storage listener", -> Settings.set 'scrollStepSize', 20 assert.equal Settings.get('scrollStepSize'), 20 - Sync.handleStorageUpdate { scrollStepSize: { newValue: "60" } } + Settings.Sync.handleStorageUpdate { scrollStepSize: { newValue: "60" } } assert.isFalse Settings.has 'scrollStepSize' should "propagate non-default values from synced storage", -> chrome.storage.sync.set { scrollStepSize: JSON.stringify(20) } - Sync.fetchAsync() assert.equal Settings.get('scrollStepSize'), 20 should "propagate default values from synced storage", -> Settings.set 'scrollStepSize', 20 chrome.storage.sync.set { scrollStepSize: JSON.stringify(60) } - Sync.fetchAsync() assert.isFalse Settings.has 'scrollStepSize' should "clear a setting from synced storage", -> @@ -66,9 +67,10 @@ context "settings", should "trigger a postUpdateHook", -> message = "Hello World" - Settings.postUpdateHooks['scrollStepSize'] = (value) -> Sync.message = value + receivedMessage = "" + Settings.postUpdateHooks['scrollStepSize'] = (value) -> receivedMessage = value chrome.storage.sync.set { scrollStepSize: JSON.stringify(message) } - assert.equal message, Sync.message + assert.equal message, receivedMessage should "sync a key which is not a known setting (without crashing)", -> chrome.storage.sync.set { notASetting: JSON.stringify("notAUsefullValue") } diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index 60f3a890..16f0e144 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -19,6 +19,9 @@ exports.chrome = onInstalled: addListener: -> + extension: + getURL: (path) -> path + tabs: onSelectionChanged: addListener: () -> true diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee index bfe066c3..f9ed3636 100644 --- a/tests/unit_tests/utils_test.coffee +++ b/tests/unit_tests/utils_test.coffee @@ -2,9 +2,8 @@ require "./test_helper.js" extend global, require "./test_chrome_stubs.js" extend(global, require "../../lib/utils.js") Utils.getCurrentVersion = -> '1.43' -extend(global, require "../../background_scripts/sync.js") -extend(global, require "../../background_scripts/settings.js") -Sync.init() +extend(global, require "../../lib/settings.js") +Settings.init() context "isUrl", should "accept valid URLs", -> |
