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