aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.travis.yml1
-rw-r--r--CONTRIBUTING.md48
-rw-r--r--README.md58
-rw-r--r--background_scripts/commands.coffee7
-rw-r--r--background_scripts/completion.coffee18
-rw-r--r--background_scripts/exclusions.coffee42
-rw-r--r--background_scripts/main.coffee80
-rw-r--r--background_scripts/settings.coffee21
-rw-r--r--content_scripts/link_hints.coffee211
-rw-r--r--content_scripts/mode.coffee202
-rw-r--r--content_scripts/mode_find.coffee66
-rw-r--r--content_scripts/mode_insert.coffee84
-rw-r--r--content_scripts/mode_passkeys.coffee24
-rw-r--r--content_scripts/mode_visual.coffee20
-rw-r--r--content_scripts/scroller.coffee282
-rw-r--r--content_scripts/ui_component.coffee54
-rw-r--r--content_scripts/vimium.css151
-rw-r--r--content_scripts/vimium_frontend.coffee501
-rw-r--r--content_scripts/vomnibar.coffee286
-rw-r--r--lib/dom_utils.coffee181
-rw-r--r--lib/handler_stack.coffee92
-rw-r--r--lib/keyboard_utils.coffee6
-rw-r--r--lib/rect.coffee82
-rw-r--r--lib/utils.coffee30
-rw-r--r--manifest.json14
-rw-r--r--pages/exclusions.html13
-rw-r--r--pages/options.coffee291
-rw-r--r--pages/options.css232
-rw-r--r--pages/options.html291
-rw-r--r--pages/popup.coffee110
-rw-r--r--pages/popup.html113
-rw-r--r--pages/ui_component_server.coffee27
-rw-r--r--pages/vomnibar.coffee235
-rw-r--r--pages/vomnibar.css136
-rw-r--r--pages/vomnibar.html22
-rw-r--r--test_harnesses/vomnibar.html2
-rw-r--r--tests/dom_tests/chrome.coffee8
-rw-r--r--tests/dom_tests/dom_tests.coffee459
-rw-r--r--tests/dom_tests/dom_tests.html8
-rw-r--r--tests/dom_tests/dom_utils_test.coffee6
-rw-r--r--tests/dom_tests/vomnibar_test.coffee25
-rw-r--r--tests/unit_tests/completion_test.coffee11
-rw-r--r--tests/unit_tests/exclusion_test.coffee50
-rw-r--r--tests/unit_tests/handler_stack_test.coffee23
-rw-r--r--tests/unit_tests/rect_test.coffee232
-rw-r--r--tests/unit_tests/settings_test.coffee13
-rw-r--r--tests/unit_tests/test_chrome_stubs.coffee6
-rw-r--r--tests/unit_tests/utils_test.coffee3
48 files changed, 3476 insertions, 1401 deletions
diff --git a/.travis.yml b/.travis.yml
index 6393bfaf..c186f393 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,6 +3,7 @@ node_js: 0.10
before_install:
- "npm install -g coffee-script"
- "npm install path"
+ - "npm install util"
- "cake build"
script: "cake test"
notifications:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9382a020..a417caf5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -74,6 +74,34 @@ reports:
When you're done with your changes, send us a pull request on Github. Feel free to include a change to the
CREDITS file with your patch.
+Vimium design goals
+-------------------
+
+When improving Vimium it's helpful to know what design goals we're optimizing for.
+
+The core goal is to make it easy to navigate the web using just the keyboard. When people first start using
+Vimium, it provides an incredibly powerful workflow improvement and it makes them feel awesome. And it turns
+out that Vimium is applicable to a huge, broad population of people, not just users of Vim, which is great.
+
+A secondary goal is to make Vimium approachable, or in other words, to minimize the barriers which will
+prevent a new user from feeling awesome. Many of Vimium's users haven't used Vim before (about 1 in 5 app
+store reviews say this), and most people have strong web browsing habits forged from years of browsing that
+they rely on. Given that, it's a great experience when Vimium feels like a natural addition to Chrome which
+augments but doesn't break their current browsing habits.
+
+In some ways, making software approachable is even harder than just enabling the core use case. But in this
+area, Vimium really shines. It's approachable today because:
+
+1. It's simple to understand (even if you're not very familiar with Vim). The Vimium video shows you all you
+ need to know to start using Vimium and feel awesome.
+2. The core feature set works in almost all cases on all sites, so Vimium feels reliable.
+3. Requires no configuration or doc-reading before it's useful. Just watch the video or hit `?`.
+4. Doesn't drastically change the way Chrome looks or behaves. You can transition into using Vimium piecemeal;
+ you don't need to jump in whole-hog from the start.
+5. The core feature set isn't overwhelming. This is easy to degrade as we evolve Vimium, so it requires active
+ effort to maintain this feel.
+6. Developers find the code is relatively simple and easy to jump into, so we have an active dev community.
+
## What makes for a good feature request/contribution to Vimium?
Good features:
@@ -96,23 +124,3 @@ We use these guidelines, in addition to the code complexity, when deciding wheth
If you're worried that a feature you plan to build won't be a good fit for core Vimium, just open a github
issue for discussion or send an email to the Vimium mailing list.
-
-## How to release Vimium to the Chrome Store
-
-This process is currently only done by Phil or Ilya.
-
-1. Increment the version number in manifest.json
-2. Update the Changelog in README.md
-
- You can see a summary of commits since the last version: `git log --oneline v1.45..`
-
-3. Push your commits
-4. Create a git tag for this newly released version
-
- git tag -a v1.45 -m "v1.45 release"
-
-5. Run `cake package`
-6. Take the distributable found in `dist` and upload it
- [here](https://chrome.google.com/webstore/developer/dashboard)
-7. Update the description in the Chrome store to include the latest version's release notes
-8. Celebrate
diff --git a/README.md b/README.md
index fc38c2b8..b39bf0c3 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,8 @@ Vimium - The Hacker's Browser
[![Build Status](https://secure.travis-ci.org/philc/vimium.png?branch=master)](https://travis-ci.org/philc/vimium)
-Vimium is a Chrome extension that provides keyboard-based navigation and control in the spirit of the Vim
-editor.
+Vimium is a Chrome extension that provides keyboard-based navigation and control of the web in the spirit of
+the Vim editor.
__Installation instructions:__
@@ -15,14 +15,16 @@ Please see
[CONTRIBUTING.md](https://github.com/philc/vimium/blob/master/CONTRIBUTING.md#installing-from-source)
for instructions on how you can install Vimium from source.
-The Options page can be reached via a link on the help dialog (hit `?`) or via the button next to Vimium on
+The Options page can be reached via a link on the help dialog (type `?`) or via the button next to Vimium on
the Chrome Extensions page (`chrome://extensions`).
Keyboard Bindings
-----------------
Modifier keys are specified as `<c-x>`, `<m-x>`, and `<a-x>` for ctrl+x, meta+x, and alt+x
-respectively. See the next section for instructions on customizing these bindings.
+respectively. See the next section for how to customize these bindings.
+
+Once you have Vimium installed, you can see this list of key bindings at any time by typing `?`.
Navigating the current page:
@@ -90,7 +92,7 @@ Additional advanced browsing commands:
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.
-There are some advanced commands which aren't documented here. Refer to the help dialog (type `?`) for a full
+There are some advanced commands which aren't documented here; refer to the help dialog (type `?`) for a full
list.
Custom Key Mappings
@@ -138,6 +140,20 @@ Please see [CONTRIBUTING.md](https://github.com/philc/vimium/blob/master/CONTRIB
Release Notes
-------------
+1.49 (2014-12-16)
+
+- An option to toggle smooth scrolling.
+- Make Vimium work on older versions of Chrome.
+
+1.46, 1.47, 1.48 (2014-12-15)
+
+- Site-specific excluded keys: you can disable some Vimium key bindings on sites like gmail.com, so you can use the key bindings provided by the site itself.
+- Smooth scrolling.
+- The Vomnibar now orders tabs by recency. Use this to quickly switch between your most recently-used tabs.
+- New commands: "close tabs to the left", "close tabs to the right", "close all other tabs".
+- Usability improvements.
+- Bug fixes.
+
1.45 (2014-07-20)
- Vimium's settings are now synced across computers.
@@ -145,12 +161,12 @@ Release Notes
- Vomnibar can now use [search engine shortcuts](https://github.com/philc/vimium/wiki/Search-Engines), similar to Chrome's Omnibar.
- Due to significant ranking improvements, Vomnibar's search results are now even more helpful.
- When reopening a closed tab, its history is now preserved.
-- Bugfixes.
+- Bug fixes.
1.44 (2013-11-06)
- Add support for recent versions of Chromium.
-- Bugfixes.
+- Bug fixes.
1.43 (2013-05-18)
@@ -162,25 +178,25 @@ Release Notes
- 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+.
-- Bugfixes.
+- Bug fixes.
1.42 (2012-11-03)
-- Bugfixes.
+- Bug fixes.
1.41 (2012-10-27)
-- Bugfixes.
+- Bug fixes.
1.40 (2012-10-27)
-- Bugfixes.
+- Bug fixes.
- Added options for search engines and regex find.
- Pressing unmapped keys in hints mode now deactivates the mode.
1.39 (2012-09-09)
-- Bugfixes.
+- Bug fixes.
1.38 (2012-09-08)
@@ -188,7 +204,7 @@ Release Notes
- Add a browser icon to quickly add sites to Vimium's exclude list.
- Restyle options page.
- `gi` now launches a new mode that allows the user to tab through the input elements on the page.
-- Bugfixes.
+- Bug fixes.
1.37 (2012-07-07)
@@ -201,11 +217,11 @@ Release Notes
1.35 (2012-07-05)
-- Bugfixes.
+- Bug fixes.
1.34 (2012-07-03)
-- A bugfix for bookmarklets in Vomnibar.
+- A bug fix for bookmarklets in Vomnibar.
1.33 (2012-07-02)
@@ -245,11 +261,11 @@ Release Notes
- Support for opening bookmarks (`b` and `B`).
- Support for contenteditable text boxes.
-- Speed improvements and bugfixes.
+- Speed improvements and bug fixes.
1.27 (2011-03-24)
-- Improvements and bugfixes.
+- Improvements and bug fixes.
1.26 (2011-02-17)
@@ -264,11 +280,11 @@ Release Notes
- Many of the less-used commands are now marked as "advanced" and hidden in the help dialog by default, so
that the core command set is more focused and approachable.
- Improvements to link hinting.
-- Bugfixes.
+- Bug fixes.
1.21 (2010-10-24)
-- Critical bugfix for an excluded URLs regression due to frame support.
+- Critical bug fix for an excluded URLs regression due to frame support.
1.20 (2010-10-24)
@@ -315,7 +331,7 @@ does not support command repetition.
- Make the CSS used by the link hints configurable. It's under Advanced Options.
- Add a notification linking to the changelog when Vimium is updated in the background.
-- Link-hinting performance improvements and bugfixes.
+- Link-hinting performance improvements and bug fixes.
- Ctrl+D and Ctrl+U now scroll by 1/2 page instead of a fixed amount, to mirror Vim's behavior.
1.14 (2010-01-21)
@@ -325,7 +341,7 @@ does not support command repetition.
1.13 (2010-01-21)
- `<c-f>` and `<c-b>` are now mapped to scroll a full page up or down respectively.
-- Bugfixes related to entering insert mode when the page first loads, and when focusing Flash embeds.
+- Bug fixes related to entering insert mode when the page first loads, and when focusing Flash embeds.
- Added command listing to the Options page for easy reference.
- J & K have reversed for tab switching: J goes left and K goes right.
- `<c-[>` is now equivalent to ESC, to match the behavior of VIM.
diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee
index 10fa323b..114f46ce 100644
--- a/background_scripts/commands.coffee
+++ b/background_scripts/commands.coffee
@@ -58,7 +58,7 @@ Commands =
for line in lines
continue if (line[0] == "\"" || line[0] == "#")
- splitLine = line.split(/\s+/)
+ splitLine = line.replace(/\s+$/, "").split(/\s+/)
lineCommand = splitLine[0]
@@ -111,6 +111,7 @@ Commands =
"goUp",
"goToRoot",
"enterInsertMode",
+ "enterVisualMode",
"focusInput",
"LinkHints.activateMode",
"LinkHints.activateModeToOpenInNewTab",
@@ -195,6 +196,7 @@ defaultKeyMappings =
"gs": "toggleViewSource"
"i": "enterInsertMode"
+ "v": "enterVisualMode"
"H": "goBack"
"L": "goForward"
@@ -207,8 +209,6 @@ defaultKeyMappings =
"F": "LinkHints.activateModeToOpenInNewTab"
"<a-f>": "LinkHints.activateModeWithQueue"
- "af": "LinkHints.activateModeToDownloadLink"
-
"/": "enterFindMode"
"n": "performFind"
"N": "performBackwardsFind"
@@ -285,6 +285,7 @@ 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 }]
focusInput: ["Focus the first text box on the page. Cycle between them using tab",
{ passCountToFunction: true }]
diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee
index dc5519d5..d6402019 100644
--- a/background_scripts/completion.coffee
+++ b/background_scripts/completion.coffee
@@ -324,11 +324,17 @@ class SearchEngineCompleter
searchEngines: {}
filter: (queryTerms, onComplete) ->
- searchEngineMatch = this.getSearchEngineMatches(queryTerms[0])
+ {url: url, description: description} = @getSearchEngineMatches queryTerms
suggestions = []
- if searchEngineMatch
- searchEngineMatch = searchEngineMatch.replace(/%s/g, queryTerms[1..].join(" "))
- suggestion = new Suggestion(queryTerms, "search", searchEngineMatch, queryTerms[0] + ": " + queryTerms[1..].join(" "), @computeRelevancy)
+ if url
+ url = url.replace(/%s/g, Utils.createSearchQuery queryTerms[1..])
+ if description
+ type = description
+ query = queryTerms[1..].join " "
+ else
+ type = "search"
+ query = queryTerms[0] + ": " + queryTerms[1..].join(" ")
+ suggestion = new Suggestion(queryTerms, type, url, query, @computeRelevancy)
suggestions.push(suggestion)
onComplete(suggestions)
@@ -337,8 +343,8 @@ class SearchEngineCompleter
refresh: ->
this.searchEngines = root.Settings.getSearchEngines()
- getSearchEngineMatches: (queryTerm) ->
- this.searchEngines[queryTerm]
+ getSearchEngineMatches: (queryTerms) ->
+ (1 < queryTerms.length and @searchEngines[queryTerms[0]]) or {}
# 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.
diff --git a/background_scripts/exclusions.coffee b/background_scripts/exclusions.coffee
index 3a8ef1e7..55ced3ef 100644
--- a/background_scripts/exclusions.coffee
+++ b/background_scripts/exclusions.coffee
@@ -6,21 +6,36 @@ RegexpCache =
if regexp = @cache[pattern]
regexp
else
- @cache[pattern] = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$")
+ @cache[pattern] =
+ # We use try/catch to ensure that a broken regexp doesn't wholly cripple Vimium.
+ try
+ new RegExp("^" + pattern.replace(/\*/g, ".*") + "$")
+ catch
+ /^$/ # Match the empty string.
# The Exclusions class manages the exclusion rule setting.
# An exclusion is an object with two attributes: pattern and passKeys.
-# The exclusions are an array of such objects (because the order matters).
+# The exclusions are an array of such objects.
root.Exclusions = Exclusions =
+ # Make RegexpCache, which is required on the page popup, accessible via the Exclusions object.
+ RegexpCache: RegexpCache
rules: Settings.get("exclusionRules")
- # Return the first exclusion rule matching the URL, or null.
- getRule: (url) ->
- for rule in @rules
- return rule if url.match(RegexpCache.get(rule.pattern))
- return null
+ # Merge the matching rules for URL, or null. In the normal case, we use the configured @rules; hence, this
+ # is the default. However, when called from the page popup, we are testing what effect candidate new rules
+ # would have on the current tab. In this case, the candidate rules are provided by the caller.
+ getRule: (url, rules=@rules) ->
+ matches = (rule for rule in rules when rule.pattern and 0 <= url.search(RegexpCache.get(rule.pattern)))
+ # An absolute exclusion rule (with no passKeys) takes priority.
+ for rule in matches
+ return rule unless rule.passKeys
+ 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 ""
+ else
+ null
setRules: (rules) ->
# Callers map a rule to null to have it deleted, and rules without a pattern are useless.
@@ -30,19 +45,6 @@ root.Exclusions = Exclusions =
postUpdateHook: (rules) ->
@rules = rules
- # Update an existing rule or add a new rule.
- updateOrAdd: (newRule) ->
- seen = false
- @rules.push(newRule)
- @setRules @rules.map (rule) ->
- if rule.pattern == newRule.pattern
- if seen then null else seen = newRule
- else
- rule
-
- remove: (pattern) ->
- @setRules(@rules.filter((rule) -> rule and rule.pattern != pattern))
-
# Development and debug only.
# Enable this (temporarily) to restore legacy exclusion rules from backup.
if false and Settings.has("excludedUrlsBackup")
diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee
index 3ec618c9..c1c8dfc8 100644
--- a/background_scripts/main.coffee
+++ b/background_scripts/main.coffee
@@ -19,6 +19,11 @@ namedKeyRegex = /^(<(?:[amc]-.|(?:[amc]-)?[a-z0-9]{2,5})>)(.*)$/
selectionChangedHandlers = []
tabLoadedHandlers = {} # tabId -> function()
+# A secret, available only within the current instantiation of Vimium. The secret is big, likely unguessable
+# in practice, but less than 2^31.
+chrome.storage.local.set
+ vimiumSecret: Math.floor Math.random() * 2000000000
+
completionSources =
bookmarks: new BookmarkCompleter()
history: new HistoryCompleter()
@@ -75,26 +80,10 @@ getCurrentTabUrl = (request, sender) -> sender.tab.url
root.isEnabledForUrl = isEnabledForUrl = (request) ->
rule = Exclusions.getRule(request.url)
{
- rule: rule
isEnabledForUrl: not rule or rule.passKeys
passKeys: rule?.passKeys or ""
}
-# Called by the popup UI.
-# If the URL pattern matches an existing rule, then the existing rule is updated. Otherwise, a new rule is created.
-root.addExclusionRule = (pattern,passKeys) ->
- if pattern = pattern.trim()
- Exclusions.updateOrAdd({ pattern: pattern, passKeys: passKeys })
- chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true },
- (tabs) -> updateActiveState(tabs[0].id))
-
-# Called by the popup UI. Remove all existing exclusion rules with this pattern.
-root.removeExclusionRule = (pattern) ->
- if pattern = pattern.trim()
- Exclusions.remove(pattern)
- chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true },
- (tabs) -> updateActiveState(tabs[0].id))
-
saveHelpDialogSettings = (request) ->
Settings.set("helpDialog_showAdvancedCommands", request.showAdvancedCommands)
@@ -350,10 +339,30 @@ updateOpenTabs = (tab) ->
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.
-updateActiveState = (tabId) ->
+# 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"
@@ -377,6 +386,7 @@ updateActiveState = (tabId) ->
else
# We didn't get a response from the front end, so Vimium isn't running.
setBrowserActionIcon(tabId,disabledIcon)
+ setBadge {badge: ""}
handleUpdateScrollPosition = (request, sender) ->
updateScrollPosition(sender.tab, request.scrollX, request.scrollY)
@@ -624,24 +634,26 @@ portHandlers =
filterCompleter: filterCompleter
sendRequestHandlers =
- getCompletionKeys: getCompletionKeysRequest,
- getCurrentTabUrl: getCurrentTabUrl,
- openUrlInNewTab: openUrlInNewTab,
- openUrlInIncognito: openUrlInIncognito,
- openUrlInCurrentTab: openUrlInCurrentTab,
- openOptionsPageInNewTab: openOptionsPageInNewTab,
- registerFrame: registerFrame,
- unregisterFrame: unregisterFrame,
- frameFocused: handleFrameFocused,
- upgradeNotificationClosed: upgradeNotificationClosed,
- updateScrollPosition: handleUpdateScrollPosition,
- copyToClipboard: copyToClipboard,
- isEnabledForUrl: isEnabledForUrl,
- saveHelpDialogSettings: saveHelpDialogSettings,
- selectSpecificTab: selectSpecificTab,
- refreshCompleter: refreshCompleter,
- createMark: Marks.create.bind(Marks),
+ getCompletionKeys: getCompletionKeysRequest
+ getCurrentTabUrl: getCurrentTabUrl
+ openUrlInNewTab: openUrlInNewTab
+ openUrlInIncognito: openUrlInIncognito
+ openUrlInCurrentTab: openUrlInCurrentTab
+ openOptionsPageInNewTab: openOptionsPageInNewTab
+ registerFrame: registerFrame
+ unregisterFrame: unregisterFrame
+ frameFocused: handleFrameFocused
+ nextFrame: (request) -> BackgroundCommands.nextFrame 1, request.frameId
+ upgradeNotificationClosed: upgradeNotificationClosed
+ updateScrollPosition: handleUpdateScrollPosition
+ copyToClipboard: copyToClipboard
+ isEnabledForUrl: isEnabledForUrl
+ saveHelpDialogSettings: saveHelpDialogSettings
+ selectSpecificTab: selectSpecificTab
+ refreshCompleter: refreshCompleter
+ createMark: Marks.create.bind(Marks)
gotoMark: Marks.goto.bind(Marks)
+ setBadge: setBadge
# Convenience function for development use.
window.runTests = -> open(chrome.runtime.getURL('tests/dom_tests/dom_tests.html'))
diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee
index d6e8fcde..2fc3b43d 100644
--- a/background_scripts/settings.coffee
+++ b/background_scripts/settings.coffee
@@ -46,13 +46,19 @@ root.Settings = Settings =
# this is a map that we use to store our search engines for use.
searchEnginesMap: {}
- # this parses the search engines settings and clears the old searchEngines and sets the new one
+ # Parse the custom search engines setting and cache it.
parseSearchEngines: (searchEnginesText) ->
@searchEnginesMap = {}
- # find the split pairs by first splitting by line then splitting on the first `: `
- split_pairs = ( pair.split( /: (.+)/, 2) for pair in searchEnginesText.split( /\n/ ) when pair[0] != "#" )
- @searchEnginesMap[a[0]] = a[1] for a in split_pairs
- @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
@@ -61,7 +67,8 @@ root.Settings = Settings =
# or strings
defaults:
scrollStepSize: 60
- keyMappings: "# Insert your prefered key mappings here."
+ smoothScroll: true
+ keyMappings: "# Insert your preferred key mappings here."
linkHintCharacters: "sadfjklewcmpgh"
linkHintNumbers: "0123456789"
filterLinkHints: false
@@ -104,7 +111,7 @@ root.Settings = Settings =
# 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"
+ searchEngines: "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s wikipedia"
newTabUrl: "chrome://newtab"
settingsVersion: Utils.getCurrentVersion()
diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee
index 24bd7126..2abfa001 100644
--- a/content_scripts/link_hints.coffee
+++ b/content_scripts/link_hints.coffee
@@ -8,13 +8,16 @@
# 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.
#
-OPEN_IN_CURRENT_TAB = {}
-OPEN_IN_NEW_BG_TAB = {}
-OPEN_IN_NEW_FG_TAB = {}
-OPEN_WITH_QUEUE = {}
-COPY_LINK_URL = {}
-OPEN_INCOGNITO = {}
-DOWNLOAD_LINK_URL = {}
+# 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.
+#
+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" }
LinkHints =
hintMarkerContainingDiv: null
@@ -36,17 +39,6 @@ LinkHints =
#
init: ->
- #
- # Generate an XPath describing what a clickable element is.
- # The final expression will be something like "//button | //xhtml:button | ..."
- # We use translate() instead of lower-case() because Chrome only supports XPath 1.0.
- #
- clickableElementsXPath: DomUtils.makeXPath(
- ["a", "area[@href]", "textarea", "button", "select",
- "input[not(@type='hidden' or @disabled or @readonly)]",
- "*[@onclick or @tabindex or @role='link' or @role='button' or contains(@class, 'button') or " +
- "@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"])
-
# We need this as a top-level function because our command system doesn't yet support arguments.
activateModeToOpenInNewTab: -> @activateMode(OPEN_IN_NEW_BG_TAB)
activateModeToOpenInNewForegroundTab: -> @activateMode(OPEN_IN_NEW_FG_TAB)
@@ -73,13 +65,13 @@ LinkHints =
@hintMarkerContainingDiv = DomUtils.addElementList(hintMarkers,
{ id: "vimiumHintMarkerContainer", className: "vimiumReset" })
- # handlerStack is declared by vimiumFrontend.js
- @handlerId = handlerStack.push({
+ @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
@@ -136,45 +128,128 @@ LinkHints =
marker
#
- # Returns all clickable elements that are not hidden and are in the current viewport.
- # We prune invisible elements partly for performance reasons, but moreso it's to decrease the number
- # of digits needed to enumerate all of the links on screen.
+ # Determine whether the element is visible and clickable. If it is, find the rect bounding the element in
+ # the viewport. There may be more than one part of element which is clickable (for example, if it's an
+ # image), therefore we always return a array of element/rect pairs (which may also be a singleton or empty).
#
- getVisibleClickableElements: ->
- resultSet = DomUtils.evaluateXPath(@clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE)
-
+ getVisibleClickable: (element) ->
+ tagName = element.tagName.toLowerCase()
+ isClickable = false
+ onlyHasTabIndex = false
visibleElements = []
- # Find all visible clickable elements.
- for i in [0...resultSet.snapshotLength] by 1
- element = resultSet.snapshotItem(i)
- clientRect = DomUtils.getVisibleClientRect(element, clientRect)
- if (clientRect != null)
- visibleElements.push({element: element, rect: clientRect})
-
- if (element.localName == "area")
- map = element.parentElement
- continue unless map
- img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']")
- continue unless img
- imgClientRects = img.getClientRects()
- continue if (imgClientRects.length == 0)
- c = element.coords.split(/,/)
- coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)]
- rect = {
- top: imgClientRects[0].top + coords[1],
- left: imgClientRects[0].left + coords[0],
- right: imgClientRects[0].left + coords[2],
- bottom: imgClientRects[0].top + coords[3],
- width: coords[2] - coords[0],
- height: coords[3] - coords[1]
- }
-
- visibleElements.push({element: element, rect: rect})
+ # Insert area elements that provide click functionality to an img.
+ if tagName == "img"
+ mapName = element.getAttribute "usemap"
+ if mapName
+ imgClientRects = element.getClientRects()
+ mapName = mapName.replace(/^#/, "").replace("\"", "\\\"")
+ map = document.querySelector "map[name=\"#{mapName}\"]"
+ if map and imgClientRects.length > 0
+ areas = map.getElementsByTagName "area"
+ areasAndRects = DomUtils.getClientRectsForAreas imgClientRects[0], areas
+ visibleElements.push areasAndRects...
+
+ # Check aria properties to see if the element should be ignored.
+ if (element.getAttribute("aria-hidden")?.toLowerCase() in ["", "true"] or
+ element.getAttribute("aria-disabled")?.toLowerCase() in ["", "true"])
+ return [] # This element should never have a link hint.
+
+ # Check for attributes that make an element clickable regardless of its tagName.
+ if (element.hasAttribute("onclick") or
+ element.getAttribute("role")?.toLowerCase() in ["button", "link"] or
+ element.getAttribute("class")?.toLowerCase().indexOf("button") >= 0 or
+ element.getAttribute("contentEditable")?.toLowerCase() in ["", "contentEditable", "true"])
+ isClickable = true
+
+ # Check for jsaction event listeners on the element.
+ if element.hasAttribute "jsaction"
+ jsactionRules = element.getAttribute("jsaction").split(";")
+ for jsactionRule in jsactionRules
+ ruleSplit = jsactionRule.split ":"
+ isClickable ||= ruleSplit[0] == "click" or (ruleSplit.length == 1 and ruleSplit[0] != "none")
+
+ # Check for tagNames which are natively clickable.
+ switch tagName
+ when "a"
+ isClickable = true
+ when "textarea"
+ isClickable ||= not element.disabled and not element.readOnly
+ when "input"
+ isClickable ||= not (element.getAttribute("type")?.toLowerCase() == "hidden" or
+ element.disabled or
+ (element.readOnly and DomUtils.isSelectable element))
+ when "button", "select"
+ isClickable ||= not element.disabled
+
+ # Elements with tabindex are sometimes useful, but usually not. We can treat them as second class
+ # citizens when it improves UX, so take special note of them.
+ tabIndexValue = element.getAttribute("tabindex")
+ tabIndex = if tabIndexValue == "" then 0 else parseInt tabIndexValue
+ unless isClickable or isNaN(tabIndex) or tabIndex < 0
+ isClickable = onlyHasTabIndex = true
+
+ if isClickable
+ clientRect = DomUtils.getVisibleClientRect element
+ if clientRect != null
+ visibleElements.push {element: element, rect: clientRect, secondClassCitizen: onlyHasTabIndex}
visibleElements
#
+ # Returns all clickable elements that are not hidden and are in the current viewport, along with rectangles
+ # at which (parts of) the elements are displayed.
+ # In the process, we try to find rects where elements do not overlap so that link hints are unambiguous.
+ # Because of this, the rects returned will frequently *NOT* be equivalent to the rects for the whole
+ # element.
+ #
+ getVisibleClickableElements: ->
+ elements = document.documentElement.getElementsByTagName "*"
+ visibleElements = []
+
+ # The order of elements here is important; they should appear in the order they are in the DOM, so that
+ # we can work out which element is on top when multiple elements overlap. Detecting elements in this loop
+ # is the sensible, efficient way to ensure this happens.
+ # NOTE(mrmr1993): Our previous method (combined XPath and DOM traversal for jsaction) couldn't provide
+ # this, so it's necessary to check whether elements are clickable in order, as we do below.
+ for element in elements
+ visibleElement = @getVisibleClickable element
+ visibleElements.push visibleElement...
+
+ # TODO(mrmr1993): Consider z-index. z-index affects behviour as follows:
+ # * The document has a local stacking context.
+ # * An element with z-index specified
+ # - sets its z-order position in the containing stacking context, and
+ # - creates a local stacking context containing its children.
+ # * An element (1) is shown above another element (2) if either
+ # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), the
+ # ancestor of (1) has a higher z-index than the ancestor of (2); or
+ # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2),
+ # + the ancestors of (1) and (2) have equal z-index, and
+ # + the ancestor of (1) appears later in the DOM than the ancestor of (2).
+ #
+ # Remove rects from elements where another clickable element lies above it.
+ nonOverlappingElements = []
+ # Traverse the DOM from first to last, since later elements show above earlier elements.
+ visibleElements = visibleElements.reverse()
+ while visibleElement = visibleElements.pop()
+ rects = [visibleElement.rect]
+ for {rect: negativeRect} in visibleElements
+ # Subtract negativeRect from every rect in rects, and concatenate the arrays of rects that result.
+ rects = [].concat (rects.map (rect) -> Rect.subtract rect, negativeRect)...
+ if rects.length > 0
+ nonOverlappingElements.push {element: visibleElement.element, rect: rects[0]}
+ else
+ # Every part of the element is covered by some other element, so just insert the whole element's
+ # rect. Except for elements with tabIndex set (second class citizens); these are often more trouble
+ # than they're worth.
+ # TODO(mrmr1993): This is probably the wrong thing to do, but we don't want to stop being able to
+ # click some elements that we could click before.
+ nonOverlappingElements.push visibleElement unless visibleElement.secondClassCitizen
+
+ nonOverlappingElements
+
+ #
# Handles shift and esc keys. The other keys are passed to getMarkerMatcher().matchHintsByKey.
#
onKeyDownInMode: (hintMarkers, event) ->
@@ -182,21 +257,31 @@ LinkHints =
if ((event.keyCode == keyCodes.shiftKey or event.keyCode == keyCodes.ctrlKey) and
(@mode == OPEN_IN_CURRENT_TAB or
+ @mode == OPEN_WITH_QUEUE or
@mode == OPEN_IN_NEW_BG_TAB or
@mode == OPEN_IN_NEW_FG_TAB))
- # Toggle whether to open link in a new or current tab.
- prev_mode = @mode
-
- if event.keyCode == keyCodes.shiftKey
- @setOpenLinkMode(if @mode is OPEN_IN_CURRENT_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_CURRENT_TAB)
-
- else # event.keyCode == keyCodes.ctrlKey
- @setOpenLinkMode(if @mode is OPEN_IN_NEW_FG_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_NEW_FG_TAB)
+ # Toggle whether to open the link in a new or current tab.
+ previousMode = @mode
+ keyCode = event.keyCode
+
+ switch keyCode
+ when keyCodes.shiftKey
+ @setOpenLinkMode(if @mode is OPEN_IN_CURRENT_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_CURRENT_TAB)
+ when keyCodes.ctrlKey
+ @setOpenLinkMode(if @mode is OPEN_IN_NEW_FG_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_NEW_FG_TAB)
+
+ handlerStack.push
+ keyup: (event) =>
+ if event.keyCode == keyCode
+ @setOpenLinkMode previousMode if @isActive
+ handlerStack.remove()
+ true
# TODO(philc): Ignore keys that have modifiers.
if (KeyboardUtils.isEscape(event))
+ DomUtils.suppressKeyupAfterEscape handlerStack
@deactivateMode()
- else
+ else if (event.keyCode != keyCodes.shiftKey and event.keyCode != keyCodes.ctrlKey)
keyResult = @getMarkerMatcher().matchHintsByKey(hintMarkers, event)
linksMatched = keyResult.linksMatched
delay = keyResult.delay ? 0
@@ -258,7 +343,7 @@ LinkHints =
if (LinkHints.hintMarkerContainingDiv)
DomUtils.removeElement LinkHints.hintMarkerContainingDiv
LinkHints.hintMarkerContainingDiv = null
- handlerStack.remove @handlerId
+ @hintMode.exit()
HUD.hide()
@isActive = false
diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee
new file mode 100644
index 00000000..acc3978e
--- /dev/null
+++ b/content_scripts/mode.coffee
@@ -0,0 +1,202 @@
+#
+# A mode implements a number of keyboard (and possibly other) event handlers which are pushed onto the handler
+# stack when the mode is activated, and popped off when it is deactivated. The Mode class constructor takes a
+# single argument "options" which can define (amongst other things):
+#
+# 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:
+# Key handlers. Optional: provide these as required. The default is to continue bubbling all key events.
+#
+# Further options are described in the constructor, below.
+#
+# Additional handlers associated with a mode can be added by using the push method. For example, if a mode
+# responds to "focus" events, then push an additional handler:
+# @push
+# "focus": (event) => ....
+# Such handlers are removed when the mode is deactivated.
+#
+# The following events can be handled:
+# keydown, keypress, keyup, click, focus and blur
+
+# Debug only.
+count = 0
+
+class Mode
+ # If Mode.debug is true, then we generate a trace of modes being activated and deactivated on the console.
+ debug: false
+ @modes: []
+
+ # Constants; short, readable names for the return values expected by handlerStack.bubbleEvent.
+ continueBubbling: true
+ suppressEvent: false
+ stopBubblingAndTrue: handlerStack.stopBubblingAndTrue
+ stopBubblingAndFalse: handlerStack.stopBubblingAndFalse
+ restartBubbling: handlerStack.restartBubbling
+
+ constructor: (@options = {}) ->
+ @handlers = []
+ @exitHandlers = []
+ @modeIsActive = true
+ @badge = @options.badge || ""
+ @name = @options.name || "anonymous"
+
+ @count = ++count
+ @id = "#{@name}-#{@count}"
+ @log "activate:", @id
+
+ @push
+ keydown: @options.keydown || null
+ keypress: @options.keypress || null
+ keyup: @options.keyup || null
+ updateBadge: (badge) => @alwaysContinueBubbling => @updateBadge badge
+
+ # If @options.exitOnEscape is truthy, then the mode will exit when the escape key is pressed.
+ if @options.exitOnEscape
+ # Note. This handler ends up above the mode's own key handlers on the handler stack, so it takes
+ # priority.
+ @push
+ _name: "mode-#{@id}/exitOnEscape"
+ "keydown": (event) =>
+ return @continueBubbling unless KeyboardUtils.isEscape event
+ DomUtils.suppressKeyupAfterEscape handlerStack
+ @exit event, event.srcElement
+ @suppressEvent
+
+ # If @options.exitOnBlur is truthy, then it should be an element. The mode will exit when that element
+ # loses the focus.
+ if @options.exitOnBlur
+ @push
+ _name: "mode-#{@id}/exitOnBlur"
+ "blur": (event) => @alwaysContinueBubbling => @exit() if event.target == @options.exitOnBlur
+
+ # If @options.exitOnClick is truthy, then the mode will exit on any click event.
+ if @options.exitOnClick
+ @push
+ _name: "mode-#{@id}/exitOnClick"
+ "click": (event) => @alwaysContinueBubbling => @exit event
+
+ # 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.
+ if @options.singleton
+ do =>
+ singletons = Mode.singletons ||= {}
+ key = @options.singleton
+ @onExit => delete singletons[key] if singletons[key] == @
+ if singletons[key]
+ @log "singleton:", "deactivating #{singletons[key].id}"
+ singletons[key].exit()
+ singletons[key] = @
+
+ # If @options.trackState is truthy, then the mode mainatins the current state in @enabled and @passKeys,
+ # and calls @registerStateChange() (if defined) whenever the state changes. The mode also tracks the
+ # current keyQueue in @keyQueue.
+ if @options.trackState
+ @enabled = false
+ @passKeys = ""
+ @keyQueue = ""
+ @push
+ _name: "mode-#{@id}/registerStateChange"
+ registerStateChange: ({ enabled: enabled, passKeys: passKeys }) => @alwaysContinueBubbling =>
+ if enabled != @enabled or passKeys != @passKeys
+ @enabled = enabled
+ @passKeys = passKeys
+ @registerStateChange?()
+ registerKeyQueue: ({ keyQueue: keyQueue }) => @alwaysContinueBubbling => @keyQueue = keyQueue
+
+ Mode.modes.push @
+ Mode.updateBadge()
+ @logModes()
+ # End of Mode constructor.
+
+ push: (handlers) ->
+ handlers._name ||= "mode-#{@id}"
+ @handlers.push handlerStack.push handlers
+
+ unshift: (handlers) ->
+ handlers._name ||= "mode-#{@id}"
+ @handlers.push handlerStack.unshift handlers
+
+ onExit: (handler) ->
+ @exitHandlers.push handler
+
+ exit: ->
+ if @modeIsActive
+ @log "deactivate:", @id
+ handler() for handler in @exitHandlers
+ handlerStack.remove handlerId for handlerId in @handlers
+ Mode.modes = Mode.modes.filter (mode) => mode != @
+ Mode.updateBadge()
+ @modeIsActive = false
+
+ # 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.
+ alwaysContinueBubbling: handlerStack.alwaysContinueBubbling
+
+ # 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
+ @log "active modes (top to bottom):"
+ @log " ", mode.id for mode in Mode.modes[..].reverse()
+
+ log: (args...) ->
+ console.log args... if @debug
+
+ # Return the must-recently activated mode (only used in tests).
+ @top: ->
+ @modes[@modes.length-1]
+
+# 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. We create the the one-and-only instance here.
+new 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
diff --git a/content_scripts/mode_find.coffee b/content_scripts/mode_find.coffee
new file mode 100644
index 00000000..dff63949
--- /dev/null
+++ b/content_scripts/mode_find.coffee
@@ -0,0 +1,66 @@
+# NOTE(smblott). Ultimately, all of the FindMode-related code should be moved here.
+
+# This prevents unmapped printable characters from being passed through to underlying page; see #1415. Only
+# used by PostFindMode, below.
+class SuppressPrintable extends Mode
+ constructor: (options) ->
+ super options
+ handler = (event) => if KeyboardUtils.isPrintable event then @suppressEvent else @continueBubbling
+ type = document.getSelection().type
+
+ # We use unshift here, so we see events after normal mode, so we only see unmapped keys.
+ @unshift
+ _name: "mode-#{@id}/suppress-printable"
+ keydown: handler
+ keypress: handler
+ keyup: (event) =>
+ # If the selection type has changed (usually, no longer "Range"), then the user is interacting with
+ # the input element, so we get out of the way. See discussion of option 5c from #1415.
+ if document.getSelection().type != type then @exit() else handler event
+
+# When we use find, the selection/focus can land in a focusable/editable element. In this situation, special
+# considerations apply. We implement three special cases:
+# 1. Disable insert mode, because the user hasn't asked to enter insert mode. We do this by using
+# InsertMode.suppressEvent.
+# 2. Prevent unmapped printable keyboard events from propagating to the page; see #1415. We do this by
+# inheriting from SuppressPrintable.
+# 3. If the very-next keystroke is Escape, then drop immediately into insert mode.
+#
+class PostFindMode extends SuppressPrintable
+ constructor: ->
+ return unless document.activeElement and DomUtils.isEditable document.activeElement
+ element = document.activeElement
+
+ super
+ name: "post-find"
+ # We show a "?" badge, but only while an Escape activates insert mode.
+ badge: "?"
+ singleton: PostFindMode
+ exitOnBlur: element
+ exitOnClick: true
+ keydown: (event) -> InsertMode.suppressEvent event # Always truthy, so always continues bubbling.
+ keypress: (event) -> InsertMode.suppressEvent event
+ keyup: (event) -> InsertMode.suppressEvent event
+
+ # If the very-next keydown is Escape, then exit immediately, thereby passing subsequent keys to the
+ # underlying insert-mode instance.
+ @push
+ _name: "mode-#{@id}/handle-escape"
+ keydown: (event) =>
+ if KeyboardUtils.isEscape event
+ DomUtils.suppressKeyupAfterEscape handlerStack
+ @exit()
+ @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
new file mode 100644
index 00000000..eac4a3d0
--- /dev/null
+++ b/content_scripts/mode_insert.coffee
@@ -0,0 +1,84 @@
+
+class InsertMode extends Mode
+ constructor: (options = {}) ->
+ # There is one permanently-installed instance of InsertMode. It tracks focus changes and
+ # activates/deactivates itself (by setting @insertModeLock) accordingly.
+ @permanent = options.permanent
+
+ # If truthy, then we were activated by the user (with "i").
+ @global = options.global
+
+ handleKeyEvent = (event) =>
+ return @continueBubbling unless @isActive event
+ return @stopBubblingAndTrue unless event.type == 'keydown' and KeyboardUtils.isEscape event
+ DomUtils.suppressKeyupAfterEscape handlerStack
+ target = event.srcElement
+ if target and DomUtils.isFocusable target
+ # Remove the focus, so the user can't just get back into insert mode by typing in the same input box.
+ # NOTE(smblott, 2014/12/22) Including embeds for .blur() etc. here is experimental. It appears to be
+ # 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()
+ @exit event, event.srcElement
+ @suppressEvent
+
+ defaults =
+ name: "insert"
+ keypress: handleKeyEvent
+ keyup: handleKeyEvent
+ keydown: handleKeyEvent
+
+ super extend defaults, options
+
+ @insertModeLock =
+ if document.activeElement and DomUtils.isEditable document.activeElement
+ # An input element is already active, so use it.
+ document.activeElement
+ else
+ null
+
+ @push
+ "blur": (event) => @alwaysContinueBubbling =>
+ target = event.target
+ # We can't rely on focus and blur events arriving in the expected order. When the active element
+ # changes, we might get "focus" before "blur". We track the active element in @insertModeLock, and
+ # exit only when that element blurs.
+ @exit event, target if @insertModeLock and target == @insertModeLock
+ "focus": (event) => @alwaysContinueBubbling =>
+ if @insertModeLock != event.target and DomUtils.isFocusable event.target
+ @activateOnElement event.target
+
+ # Only for tests. This gives us a hook to test the status of the permanent instance.
+ InsertMode.permanentInstance = @ if @permanent
+
+ isActive: (event) ->
+ return false if event == InsertMode.suppressedEvent
+ return true if @insertModeLock or @global
+ # Some sites (e.g. inbox.google.com) change the contentEditable property on the fly (see #1245); and
+ # unfortunately, the focus event fires *before* the change. Therefore, we need to re-check whether the
+ # active element is contentEditable.
+ @activateOnElement document.activeElement if document.activeElement?.isContentEditable
+ @insertModeLock != null
+
+ activateOnElement: (element) ->
+ @log "#{@id}: activating (permanent)" if @debug and @permanent
+ @insertModeLock = element
+ Mode.updateBadge()
+
+ exit: (_, target) ->
+ # Note: target == undefined, here, is required only for tests.
+ 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 ||= "I" if @isActive badge
+
+ # Static stuff. This allows PostFindMode to suppress the permanently-installed InsertMode instance.
+ @suppressedEvent: null
+ @suppressEvent: (event) -> @suppressedEvent = event
+
+root = exports ? window
+root.InsertMode = InsertMode
diff --git a/content_scripts/mode_passkeys.coffee b/content_scripts/mode_passkeys.coffee
new file mode 100644
index 00000000..64db5447
--- /dev/null
+++ b/content_scripts/mode_passkeys.coffee
@@ -0,0 +1,24 @@
+
+class PassKeysMode extends Mode
+ constructor: ->
+ 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
+
+ # 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) ->
+ 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.coffee b/content_scripts/mode_visual.coffee
new file mode 100644
index 00000000..2580106d
--- /dev/null
+++ b/content_scripts/mode_visual.coffee
@@ -0,0 +1,20 @@
+
+class VisualMode extends Mode
+ constructor: (element=null) ->
+ super
+ name: "visual"
+ badge: "V"
+ exitOnEscape: true
+ exitOnBlur: element
+
+ keydown: (event) =>
+ return @suppressEvent
+
+ keypress: (event) =>
+ return @suppressEvent
+
+ keyup: (event) =>
+ return @suppressEvent
+
+root = exports ? window
+root.VisualMode = VisualMode
diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee
index b3a14c78..6e2e1ffc 100644
--- a/content_scripts/scroller.coffee
+++ b/content_scripts/scroller.coffee
@@ -1,94 +1,246 @@
-window.Scroller = root = {}
-
#
# activatedElement is different from document.activeElement -- the latter seems to be reserved mostly for
# input elements. This mechanism allows us to decide whether to scroll a div or to scroll the whole document.
#
activatedElement = null
-root.init = ->
- handlerStack.push DOMActivate: -> activatedElement = event.target
+# Return 0, -1 or 1: the sign of the argument.
+# NOTE(smblott; 2014/12/17) We would like to use Math.sign(). However, according to this site
+# (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sign) Math.sign() was
+# only introduced in Chrome 38. This caused problems in R1.48 for users with old Chrome installations. We
+# can replace this with Math.sign() at some point.
+getSign = (val) ->
+ if not val
+ 0
+ else
+ if val < 0 then -1 else 1
scrollProperties =
x: {
axisName: 'scrollLeft'
max: 'scrollWidth'
- viewSize: 'clientHeight'
+ viewSize: 'clientWidth'
}
y: {
axisName: 'scrollTop'
max: 'scrollHeight'
- viewSize: 'clientWidth'
+ viewSize: 'clientHeight'
}
-getDimension = (el, direction, name) ->
- # the clientSizes of the body are the dimensions of the entire page, but the viewport should only be the
- # part visible through the window
- if name is 'viewSize' and el is document.body
- if direction is 'x' then window.innerWidth else window.innerHeight
+# Translate a scroll request into a number (which will be interpreted by `scrollBy` as a relative amount, or
+# by `scrollTo` as an absolute amount). :direction must be "x" or "y". :amount may be either a number (in
+# which case it is simply returned) or a string. If :amount is a string, then it is either "max" (meaning the
+# height or width of element), or "viewSize". In both cases, we look up and return the requested amount,
+# either in `element` or in `window`, as appropriate.
+getDimension = (el, direction, amount) ->
+ if Utils.isString amount
+ name = amount
+ # the clientSizes of the body are the dimensions of the entire page, but the viewport should only be the
+ # part visible through the window
+ if name is 'viewSize' and el is document.body
+ # TODO(smblott) Should we not be returning the width/height of element, here?
+ if direction is 'x' then window.innerWidth else window.innerHeight
+ else
+ el[scrollProperties[direction][name]]
else
- el[scrollProperties[direction][name]]
+ amount
-# Chrome does not report scrollHeight accurately for nodes with pseudo-elements of height 0 (bug 110149).
-# Therefore we cannot figure out if we have scrolled to the bottom of an element by testing if scrollTop +
-# clientHeight == scrollHeight. So just try to increase scrollTop blindly -- if it fails we know we have
-# reached the end of the content.
-ensureScrollChange = (direction, changeFn) ->
+# Perform a scroll. Return true if we successfully scrolled by any amount, and false otherwise.
+performScroll = (element, direction, amount) ->
axisName = scrollProperties[direction].axisName
- element = activatedElement
- loop
- oldScrollValue = element[axisName]
- # Elements with `overflow: hidden` should not be scrolled.
- overflow = window.getComputedStyle(element).getPropertyValue("overflow-#{direction}")
- changeFn(element, axisName) unless overflow == "hidden"
- break unless element[axisName] == oldScrollValue && element != document.body
- # we may have an orphaned element. if so, just scroll the body element.
- element = element.parentElement || document.body
-
- # if the activated element has been scrolled completely offscreen, subsequent changes in its scroll
- # position will not provide any more visual feedback to the user. therefore we deactivate it so that
- # subsequent scrolls only move the parent element.
+ before = element[axisName]
+ element[axisName] += amount
+ element[axisName] != before
+
+# Test whether `element` should be scrolled. E.g. hidden elements should not be scrolled.
+shouldScroll = (element, direction) ->
+ computedStyle = window.getComputedStyle(element)
+ # Elements with `overflow: hidden` must not be scrolled.
+ return false if computedStyle.getPropertyValue("overflow-#{direction}") == "hidden"
+ # Elements which are not visible should not be scrolled.
+ return false if computedStyle.getPropertyValue("visibility") in ["hidden", "collapse"]
+ return false if computedStyle.getPropertyValue("display") == "none"
+ true
+
+# Test whether element does actually scroll in the direction required when asked to do so. Due to chrome bug
+# 110149, scrollHeight and clientHeight cannot be used to reliably determine whether an element will scroll.
+# Instead, we scroll the element by 1 or -1 and see if it moved (then put it back). :factor is the factor by
+# which :scrollBy and :scrollTo will later scale the scroll amount. :factor can be negative, so we need it
+# here in order to decide whether we should test a forward scroll or a backward scroll.
+# Bug last verified in Chrome 38.0.2125.104.
+doesScroll = (element, direction, amount, factor) ->
+ # amount is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls (only
+ # gg, G, and friends), amount can be either a string ("max" or "viewSize") or zero. In the former case,
+ # we're definitely scrolling forwards, so any positive value will do for delta. In the latter, we're
+ # definitely scrolling backwards, so a delta of -1 will do. For absolute scrolls, factor is always 1.
+ delta = factor * getDimension(element, direction, amount) || -1
+ delta = getSign delta # 1 or -1
+ performScroll(element, direction, delta) and performScroll(element, direction, -delta)
+
+# From element and its parents, find the first which we should scroll and which does scroll.
+findScrollableElement = (element, direction, amount, factor) ->
+ while element != document.body and
+ not (doesScroll(element, direction, amount, factor) and shouldScroll(element, direction))
+ element = element.parentElement || document.body
+ element
+
+# On some pages, document.body is not scrollable. Here, we search the document for the largest visible
+# element which does scroll vertically. This is used to initialize activatedElement. See #1358.
+firstScrollableElement = (element=document.body) ->
+ if doesScroll(element, "y", 1, 1) or doesScroll(element, "y", -1, 1)
+ element
+ else
+ children = ({element: child, rect: DomUtils.getVisibleClientRect(child)} for child in element.children)
+ children = children.filter (child) -> child.rect # Filter out non-visible elements.
+ children.map (child) -> child.area = child.rect.width * child.rect.height
+ for child in children.sort((a,b) -> b.area - a.area) # Largest to smallest by visible area.
+ return ele if ele = firstScrollableElement child.element
+ null
+
+checkVisibility = (element) ->
+ # If the activated element has been scrolled completely offscreen, then subsequent changes in its scroll
+ # position will not provide any more visual feedback to the user. Therefore, we deactivate it so that
+ # subsequent scrolls affect the parent element.
rect = activatedElement.getBoundingClientRect()
if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth)
activatedElement = element
-# scroll the active element in :direction by :amount * :factor.
-# :factor is needed because :amount can take on string values, which scrollBy converts to element dimensions.
-root.scrollBy = (direction, amount, factor = 1) ->
- # if this is called before domReady, just use the window scroll function
- if (!document.body and amount instanceof Number)
- if (direction == "x")
- window.scrollBy(amount, 0)
- else
- window.scrollBy(0, amount)
- return
+# How scrolling is handled by CoreScroller.
+# - For jump scrolling, the entire scroll happens immediately.
+# - For smooth scrolling with distinct key presses, a separate animator is initiated for each key press.
+# Therefore, several animators may be active at the same time. This ensures that two quick taps on `j`
+# scroll to the same position as two slower taps.
+# - For smooth scrolling with keyboard repeat (continuous scrolling), the most recently-activated animator
+# continues scrolling at least until its keyup event is received. We never initiate a new animator on
+# keyboard repeat.
- if (!activatedElement || !isRendered(activatedElement))
- activatedElement = document.body
+# CoreScroller contains the core function (scroll) and logic for relative scrolls. All scrolls are ultimately
+# translated to relative scrolls. CoreScroller is not exported.
+CoreScroller =
+ init: (frontendSettings) ->
+ @settings = frontendSettings
+ @time = 0
+ @lastEvent = null
+ @keyIsDown = false
- ensureScrollChange direction, (element, axisName) ->
- if Utils.isString amount
- elementAmount = getDimension element, direction, amount
- else
- elementAmount = amount
- elementAmount *= factor
- element[axisName] += elementAmount
+ handlerStack.push
+ _name: 'scroller/track-key-status'
+ keydown: (event) =>
+ handlerStack.alwaysContinueBubbling =>
+ @keyIsDown = true
+ @lastEvent = event
+ keyup: =>
+ handlerStack.alwaysContinueBubbling =>
+ @keyIsDown = false
+ @time += 1
-root.scrollTo = (direction, pos) ->
- return unless document.body
+ # Return true if CoreScroller would not initiate a new scroll right now.
+ wouldNotInitiateScroll: -> @lastEvent?.repeat and @settings.get "smoothScroll"
- if (!activatedElement || !isRendered(activatedElement))
- activatedElement = document.body
+ # Calibration fudge factors for continuous scrolling. The calibration value starts at 1.0. We then
+ # increase it (until it exceeds @maxCalibration) if we guess that the scroll is too slow, or decrease it
+ # (until it is less than @minCalibration) if we guess that the scroll is too fast. The cutoff point for
+ # which guess we make is @calibrationBoundary. We require: 0 < @minCalibration <= 1 <= @maxCalibration.
+ minCalibration: 0.5 # Controls how much we're willing to slow scrolls down; smaller means more slow down.
+ maxCalibration: 1.6 # Controls how much we're willing to speed scrolls up; bigger means more speed up.
+ calibrationBoundary: 150 # Boundary between scrolls which are considered too slow, or too fast.
- ensureScrollChange direction, (element, axisName) ->
- if Utils.isString pos
- elementPos = getDimension element, direction, pos
- else
- elementPos = pos
- element[axisName] = elementPos
-
-# TODO refactor and put this together with the code in getVisibleClientRect
-isRendered = (element) ->
- computedStyle = window.getComputedStyle(element, null)
- return !(computedStyle.getPropertyValue("visibility") != "visible" ||
- computedStyle.getPropertyValue("display") == "none")
+ # Scroll element by a relative amount (a number) in some direction.
+ scroll: (element, direction, amount) ->
+ return unless amount
+
+ unless @settings.get "smoothScroll"
+ # Jump scrolling.
+ performScroll element, direction, amount
+ checkVisibility element
+ return
+
+ # We don't activate new animators on keyboard repeats; rather, the most-recently activated animator
+ # continues scrolling.
+ return if @lastEvent?.repeat
+
+ activationTime = ++@time
+ myKeyIsStillDown = => @time == activationTime and @keyIsDown
+
+ # Store amount's sign and make amount positive; the arithmetic is clearer when amount is positive.
+ sign = getSign amount
+ amount = Math.abs amount
+
+ # Initial intended scroll duration (in ms). We allow a bit longer for longer scrolls.
+ duration = Math.max 100, 20 * Math.log amount
+
+ totalDelta = 0
+ totalElapsed = 0.0
+ calibration = 1.0
+ previousTimestamp = null
+
+ animate = (timestamp) =>
+ previousTimestamp ?= timestamp
+ return requestAnimationFrame(animate) if timestamp == previousTimestamp
+
+ # The elapsed time is typically about 16ms.
+ elapsed = timestamp - previousTimestamp
+ totalElapsed += elapsed
+ previousTimestamp = timestamp
+
+ # The constants in the duration calculation, above, are chosen to provide reasonable scroll speeds for
+ # distinct keypresses. For continuous scrolls, some scrolls are too slow, and others too fast. Here, we
+ # speed up the slower scrolls, and slow down the faster scrolls.
+ if myKeyIsStillDown() and 75 <= totalElapsed and @minCalibration <= calibration <= @maxCalibration
+ calibration *= 1.05 if 1.05 * calibration * amount < @calibrationBoundary # Speed up slow scrolls.
+ calibration *= 0.95 if @calibrationBoundary < 0.95 * calibration * amount # Slow down fast scrolls.
+
+ # Calculate the initial delta, rounding up to ensure progress. Then, adjust delta to account for the
+ # current scroll state.
+ delta = Math.ceil amount * (elapsed / duration) * calibration
+ delta = if myKeyIsStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta
+
+ if delta and performScroll element, direction, sign * delta
+ totalDelta += delta
+ requestAnimationFrame animate
+ else
+ # We're done.
+ checkVisibility element
+
+ # Launch animator.
+ requestAnimationFrame animate
+
+# Scroller contains the two main scroll functions (scrollBy and scrollTo) which are exported to clients.
+Scroller =
+ init: (frontendSettings) ->
+ handlerStack.push
+ _name: 'scroller/active-element'
+ DOMActivate: (event) -> handlerStack.alwaysContinueBubbling -> activatedElement = event.target
+ CoreScroller.init frontendSettings
+
+ # scroll the active element in :direction by :amount * :factor.
+ # :factor is needed because :amount can take on string values, which scrollBy converts to element dimensions.
+ scrollBy: (direction, amount, factor = 1) ->
+ # if this is called before domReady, just use the window scroll function
+ if (!document.body and amount instanceof Number)
+ if (direction == "x")
+ window.scrollBy(amount, 0)
+ else
+ window.scrollBy(0, amount)
+ return
+
+ activatedElement ||= document.body and firstScrollableElement()
+ return unless activatedElement
+
+ # Avoid the expensive scroll calculation if it will not be used. This reduces costs during smooth,
+ # continuous scrolls, and is just an optimization.
+ unless CoreScroller.wouldNotInitiateScroll()
+ element = findScrollableElement activatedElement, direction, amount, factor
+ elementAmount = factor * getDimension element, direction, amount
+ CoreScroller.scroll element, direction, elementAmount
+
+ scrollTo: (direction, pos) ->
+ activatedElement ||= document.body and firstScrollableElement()
+ return unless activatedElement
+
+ element = findScrollableElement activatedElement, direction, pos, 1
+ amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName]
+ CoreScroller.scroll element, direction, amount
+
+root = exports ? window
+root.Scroller = Scroller
diff --git a/content_scripts/ui_component.coffee b/content_scripts/ui_component.coffee
new file mode 100644
index 00000000..c4ed3bf6
--- /dev/null
+++ b/content_scripts/ui_component.coffee
@@ -0,0 +1,54 @@
+class UIComponent
+ iframeElement: null
+ iframePort: null
+ showing: null
+
+ constructor: (iframeUrl, className, @handleMessage) ->
+ @iframeElement = document.createElement "iframe"
+ @iframeElement.className = className
+ @iframeElement.seamless = "seamless"
+ @iframeElement.src = chrome.runtime.getURL iframeUrl
+ @iframeElement.addEventListener "load", => @openPort()
+ document.documentElement.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()
+
+ show: (message) ->
+ @postMessage message if message?
+ @iframeElement.classList.remove "vimiumUIComponentHidden"
+ @iframeElement.classList.add "vimiumUIComponentShowing"
+ @showing = true
+
+ hide: (focusWindow = true)->
+ @iframeElement.classList.remove "vimiumUIComponentShowing"
+ @iframeElement.classList.add "vimiumUIComponentHidden"
+ window.focus() if focusWindow
+ @showing = false
+
+root = exports ? window
+root.UIComponent = UIComponent
diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css
index 9e25c59e..fb8824c2 100644
--- a/content_scripts/vimium.css
+++ b/content_scripts/vimium.css
@@ -2,6 +2,10 @@
* Many CSS class names in this file use the verbose "vimiumXYZ" as the class name. This is so we don't
* use the same CSS class names that the page is using, so the page's CSS doesn't mess with the style of our
* Vimium dialogs.
+ *
+ * The z-indexes of Vimium elements are very large, because we always want them to show on top. Chrome may
+ * support up to Number.MAX_VALUE, which is approximately 1.7976e+308. We're using 2^31, which is the max
+ * value of a singed 32 bit int. Let's try larger valeus if 2**31 empirically isn't large enough.
*/
/*
@@ -54,7 +58,7 @@ tr.vimiumReset {
vertical-align: baseline;
white-space: normal;
width: auto;
- z-index: 99999999;
+ z-index: 2147483648;
}
/* Linkhints CSS */
@@ -93,6 +97,7 @@ div.internalVimiumInputHint {
display: block;
background-color: rgba(255, 247, 133, 0.3);
border: solid 1px #C38A22;
+ pointer-events: none;
}
div.internalVimiumSelectedInputHint {
@@ -122,7 +127,8 @@ div#vimiumHelpDialog {
top:50px;
-webkit-box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 6px;
overflow-y: auto;
- z-index:99999998;
+ /* One less than vimiumReset */
+ z-index: 2147483647;
}
div#vimiumHelpDialog a { color:blue; }
@@ -231,8 +237,8 @@ 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 panel's close button. */
- z-index: 99999997;
+ /* 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;
@@ -264,140 +270,47 @@ div.vimiumHUD a.close-button:hover {
body.vimiumFindMode ::selection {
background: #ff9632;
-};
+}
-/* Vomnibar CSS */
+/* Vomnibar Frame CSS */
-#vomnibar ol, #vomnibar ul {
- list-style: none;
- display: block;
-}
+iframe.vomnibarFrame {
+ background-color: transparent;
+ padding: 0px;
+ overflow: hidden;
-#vomnibar {
display: block;
position: fixed;
- width: 80%;
+ width: calc(80% + 20px); /* same adjustment as in pages/vomnibar.coffee */
min-width: 400px;
+ height: calc(100% - 70px);
top: 70px;
left: 50%;
margin: 0 0 0 -40%;
+ border: none;
font-family: sans-serif;
- background: #F1F1F1;
- text-align: left;
- border-radius: 4px;
- box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8);
- border: 1px solid #aaa;
/* One less than hint markers and the help dialog. */
- z-index: 99999996;
-}
-
-#vomnibar input {
- color: #000;
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
- font-size: 20px;
- height: 34px;
- margin-bottom: 0;
- padding: 4px;
- background-color: white;
- border-radius: 3px;
- border: 1px solid #E8E8E8;
- box-shadow: #444 0px 0px 1px;
- width: 100%;
- outline: none;
- box-sizing: border-box;
-}
-
-#vomnibar .vomnibarSearchArea {
- display: block;
- padding: 10px;
- background-color: #F1F1F1;
- border-radius: 4px 4px 0 0;
- border-bottom: 1px solid #C6C9CE;
-}
-
-#vomnibar ul {
- background-color: white;
- border-radius: 0 0 4px 4px;
- list-style: none;
- padding: 10px 0;
- padding-top: 0;
-}
-
-#vomnibar li {
- border-bottom: 1px solid #ddd;
- line-height: 1.1em;
- padding: 7px 10px;
- font-size: 16px;
- color: black;
- position: relative;
- display: list-item;
- margin: auto;
-}
-
-#vomnibar li:last-of-type {
- border-bottom: none;
-}
-
-#vomnibar li .vomnibarTopHalf, #vomnibar li .vomnibarBottomHalf {
- display: block;
- overflow: hidden;
-}
-
-#vomnibar li .vomnibarBottomHalf {
- font-size: 15px;
- margin-top: 3px;
- padding: 2px 0;
+ z-index: 2147483645;
}
-#vomnibar li .vomnibarSource {
- color: #777;
- margin-right: 4px;
-}
-#vomnibar li .vomnibarRelevancy {
+div#vimiumFlash {
+ box-shadow: 0px 0px 4px 2px #4183C4;
+ padding: 1px;
+ background-color: transparent;
position: absolute;
- right: 0;
- top: 0;
- padding: 5px;
- background-color: white;
- color: black;
- font-family: monospace;
- width: 100px;
- overflow: hidden;
-}
-
-#vomnibar li .vomnibarUrl {
- white-space: nowrap;
- color: #224684;
-}
-
-#vomnibar li .vomnibarMatch {
- font-weight: bold;
- color: black;
+ z-index: 2147483648;
}
-#vomnibar li em, #vomnibar li .vomnibarTitle {
- color: black;
- margin-left: 4px;
- font-weight: normal;
-}
-#vomnibar li em { font-style: italic; }
-#vomnibar li em .vomnibarMatch, #vomnibar li .vomnibarTitle .vomnibarMatch {
- color: #333;
- text-decoration: underline;
+/* UIComponent CSS */
+iframe.vimiumUIComponentHidden {
+ display: none;
}
-#vomnibar li.vomnibarSelected {
- background-color: #BBCEE9;
- font-weight: normal;
+iframe.vimiumUIComponentVisible {
+ display: block;
}
-
-
-div#vimiumFlash {
- box-shadow: 0px 0px 4px 2px #4183C4;
- padding: 1px;
- background-color: transparent;
- position: absolute;
- z-index: 99999;
+iframe.vimiumUIComponentReactivated {
+ border: 5px solid yellow;
}
diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee
index d5586bd8..725d8a53 100644
--- a/content_scripts/vimium_frontend.coffee
+++ b/content_scripts/vimium_frontend.coffee
@@ -4,13 +4,13 @@
# background page that we're in domReady and ready to accept normal commands by connectiong to a port named
# "domReady".
#
-window.handlerStack = new HandlerStack
-insertModeLock = null
+targetElement = null
findMode = false
findModeQuery = { rawQuery: "", matchCount: 0 }
findModeQueryHasResults = false
findModeAnchorNode = null
+findModeInitialRange = null
isShowingHelpDialog = false
keyPort = null
# Users can disable Vimium on URL patterns via the settings page. The following two variables
@@ -21,8 +21,8 @@ isEnabledForUrl = true
passKeys = null
keyQueue = null
# The user's operating system.
-currentCompletionKeys = null
-validFirstKeys = null
+currentCompletionKeys = ""
+validFirstKeys = ""
# 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
@@ -49,7 +49,7 @@ settings =
loadedValues: 0
valuesToLoad: ["scrollStepSize", "linkHintCharacters", "linkHintNumbers", "filterLinkHints", "hideHud",
"previousPatterns", "nextPatterns", "findModeRawQuery", "regexFindMode", "userDefinedLinkHintCss",
- "helpDialog_showAdvancedCommands"]
+ "helpDialog_showAdvancedCommands", "smoothScroll"]
isLoaded: false
eventListeners: {}
@@ -57,6 +57,15 @@ settings =
@port = chrome.runtime.connect({ name: "settings" })
@port.onMessage.addListener(@receiveMessage)
+ # 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.
+ @port.onDisconnect.addListener =>
+ @port = null
+ for own property, value of this
+ # @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) ->
@@ -101,7 +110,21 @@ initializePreDomReady = ->
settings.addEventListener("load", LinkHints.init.bind(LinkHints))
settings.load()
- Scroller.init()
+ class NormalMode extends Mode
+ constructor: ->
+ super
+ name: "normal"
+ 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 NormalMode
+ new PassKeysMode
+ new InsertMode permanent: true
checkIfEnabledForUrl()
@@ -109,6 +132,13 @@ initializePreDomReady = ->
# Send the key to the key handler in the background page.
keyPort = chrome.runtime.connect({ name: "keyDown" })
+ # If the port is closed, the background page has gone away (since we never close it ourselves). Disable all
+ # our event listeners, and stub out chrome.runtime.sendMessage/connect (to prevent errors).
+ # TODO(mrmr1993): Do some actual cleanup to free resources, hide UI, etc.
+ keyPort.onDisconnect.addListener ->
+ isEnabledForUrl = false
+ chrome.runtime.sendMessage = ->
+ chrome.runtime.connect = ->
requestHandlers =
hideUpgradeNotification: -> HUD.hideUpgradeNotification()
@@ -120,9 +150,11 @@ initializePreDomReady = ->
getScrollPosition: -> scrollX: window.scrollX, scrollY: window.scrollY
setScrollPosition: (request) -> setScrollPosition request.scrollX, request.scrollY
executePageCommand: executePageCommand
- getActiveState: -> { enabled: isEnabledForUrl, passKeys: passKeys }
+ getActiveState: getActiveState
setState: setState
- currentKeyQueue: (request) -> keyQueue = request.keyQueue
+ currentKeyQueue: (request) ->
+ keyQueue = request.keyQueue
+ handlerStack.bubbleEvent "registerKeyQueue", { keyQueue: keyQueue }
chrome.runtime.onMessage.addListener (request, sender, sendResponse) ->
# In the options page, we will receive requests from both content and background scripts. ignore those
@@ -153,11 +185,8 @@ initializeWhenEnabled = (newPassKeys) ->
if (!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.
- installListener window, "keydown", onKeydown
- installListener window, "keypress", onKeypress
- installListener window, "keyup", onKeyup
- installListener document, "focus", onFocusCapturePhase
- installListener document, "blur", onBlurCapturePhase
+ for type in ["keydown", "keypress", "keyup", "click", "focus", "blur"]
+ do (type) -> installListener window, type, (event) -> handlerStack.bubbleEvent type, event
installListener document, "DOMActivate", onDOMActivate
enterInsertModeIfElementIsFocused()
installedListeners = true
@@ -166,6 +195,13 @@ setState = (request) ->
initializeWhenEnabled(request.passKeys) if request.enabled
isEnabledForUrl = request.enabled
passKeys = request.passKeys
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: request.enabled
+ passKeys: request.passKeys
+
+getActiveState = ->
+ Mode.updateBadge()
+ return { enabled: isEnabledForUrl, passKeys: passKeys }
#
# The backend needs to know which frame has focus.
@@ -183,10 +219,12 @@ initializeOnDomReady = ->
# Tell the background page we're in the dom ready state.
chrome.runtime.connect({ name: "domReady" })
+ CursorHider.init()
+ Vomnibar.init()
registerFrame = ->
# Don't register frameset containers; focusing them is no use.
- if document.body.tagName != "FRAMESET"
+ unless document.body?.tagName.toLowerCase() == "frameset"
chrome.runtime.sendMessage
handler: "registerFrame"
frameId: frameId
@@ -225,6 +263,12 @@ 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
@@ -281,68 +325,117 @@ extend window,
HUD.showForDuration("Yanked URL", 1000)
- focusInput: (count) ->
- # Focus the first input element on the page, and create overlays to highlight all the input elements, with
- # the currently-focused element highlighted specially. Tabbing will shift focus to the next input element.
- # Pressing any other key will remove the overlays and the special tab behavior.
- resultSet = DomUtils.evaluateXPath(textInputXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE)
- visibleInputs =
- for i in [0...resultSet.snapshotLength] by 1
- element = resultSet.snapshotItem(i)
- rect = DomUtils.getVisibleClientRect(element)
- continue if rect == null
- { element: element, rect: rect }
-
- return if visibleInputs.length == 0
-
- selectedInputIndex = Math.min(count - 1, visibleInputs.length - 1)
-
- visibleInputs[selectedInputIndex].element.focus()
-
- return if visibleInputs.length == 1
-
- hints = for tuple in visibleInputs
- hint = document.createElement("div")
- hint.className = "vimiumReset internalVimiumInputHint vimiumInputHint"
-
- # minus 1 for the border
- hint.style.left = (tuple.rect.left - 1) + window.scrollX + "px"
- hint.style.top = (tuple.rect.top - 1) + window.scrollY + "px"
- hint.style.width = tuple.rect.width + "px"
- hint.style.height = tuple.rect.height + "px"
-
- hint
-
- hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint'
-
- hintContainingDiv = DomUtils.addElementList(hints,
- { id: "vimiumInputMarkerContainer", className: "vimiumReset" })
-
- handlerStack.push keydown: (event) ->
- if event.keyCode == KeyboardUtils.keyCodes.tab
- hints[selectedInputIndex].classList.remove 'internalVimiumSelectedInputHint'
- if event.shiftKey
- if --selectedInputIndex == -1
- selectedInputIndex = hints.length - 1
+ enterInsertMode: ->
+ new InsertMode global: true
+
+ enterVisualMode: =>
+ new VisualMode()
+
+ focusInput: do ->
+ # Track the most recently focused input element.
+ recentlyFocusedElement = null
+ handlerStack.push
+ _name: "focus-input-tracker"
+ focus: (event) ->
+ recentlyFocusedElement = event.target if DomUtils.isEditable event.target
+ true
+
+ (count) ->
+ # Focus the first input element on the page, and create overlays to highlight all the input elements, with
+ # the currently-focused element highlighted specially. Tabbing will shift focus to the next input element.
+ # Pressing any other key will remove the overlays and the special tab behavior.
+ resultSet = DomUtils.evaluateXPath textInputXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE
+ visibleInputs =
+ for i in [0...resultSet.snapshotLength] by 1
+ element = resultSet.snapshotItem i
+ rect = DomUtils.getVisibleClientRect element
+ continue if rect == null
+ { element: element, rect: rect }
+
+ return if visibleInputs.length == 0
+
+ selectedInputIndex =
+ if count == 1
+ # As the starting index, we pick that of the most recently focused input element (or 0).
+ elements = visibleInputs.map (visibleInput) -> visibleInput.element
+ Math.max 0, elements.indexOf recentlyFocusedElement
else
- if ++selectedInputIndex == hints.length
- selectedInputIndex = 0
- hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint'
- visibleInputs[selectedInputIndex].element.focus()
- else unless event.keyCode == KeyboardUtils.keyCodes.shiftKey
- DomUtils.removeElement hintContainingDiv
- @remove()
- return true
-
- false
+ Math.min(count, visibleInputs.length) - 1
+
+ hints = for tuple in visibleInputs
+ hint = document.createElement "div"
+ hint.className = "vimiumReset internalVimiumInputHint vimiumInputHint"
+
+ # minus 1 for the border
+ hint.style.left = (tuple.rect.left - 1) + window.scrollX + "px"
+ hint.style.top = (tuple.rect.top - 1) + window.scrollY + "px"
+ hint.style.width = tuple.rect.width + "px"
+ hint.style.height = tuple.rect.height + "px"
+
+ hint
+
+ new class FocusSelector extends Mode
+ constructor: ->
+ super
+ name: "focus-selector"
+ badge: "?"
+ # We share a singleton with PostFindMode. That way, a new FocusSelector displaces any existing
+ # PostFindMode.
+ singleton: PostFindMode
+ exitOnClick: true
+ keydown: (event) =>
+ if event.keyCode == KeyboardUtils.keyCodes.tab
+ hints[selectedInputIndex].classList.remove 'internalVimiumSelectedInputHint'
+ selectedInputIndex += hints.length + (if event.shiftKey then -1 else 1)
+ selectedInputIndex %= hints.length
+ hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint'
+ visibleInputs[selectedInputIndex].element.focus()
+ @suppressEvent
+ else unless event.keyCode == KeyboardUtils.keyCodes.shiftKey
+ @exit()
+ @continueBubbling
+
+ @onExit -> DomUtils.removeElement hintContainingDiv
+ hintContainingDiv = DomUtils.addElementList hints,
+ id: "vimiumInputMarkerContainer"
+ className: "vimiumReset"
+
+ visibleInputs[selectedInputIndex].element.focus()
+ if visibleInputs.length == 1
+ @exit()
+ else
+ hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint'
# Decide whether this keyChar should be passed to the underlying page.
# 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.
isPassKey = ( keyChar ) ->
+ return false # Disabled.
return !keyQueue and passKeys and 0 <= passKeys.indexOf(keyChar)
-handledKeydownEvents = []
+# Track which keydown events we have handled, so that we can subsequently suppress the corresponding keyup
+# event.
+KeydownEvents =
+ handledEvents: {}
+
+ stringify: (event) ->
+ JSON.stringify
+ metaKey: event.metaKey
+ altKey: event.altKey
+ ctrlKey: event.ctrlKey
+ keyIdentifier: event.keyIdentifier
+ keyCode: event.keyCode
+
+ push: (event) ->
+ @handledEvents[@stringify event] = true
+
+ # Yields truthy or falsy depending upon whether a corresponding keydown event is present (and removes that
+ # event).
+ pop: (event) ->
+ detailString = @stringify event
+ value = @handledEvents[detailString]
+ delete @handledEvents[detailString]
+ value
#
# Sends everything except i & ESC to the handler in background_page. i & ESC are special because they control
@@ -351,9 +444,8 @@ handledKeydownEvents = []
#
# Note that some keys will only register keydown events and not keystroke events, e.g. ESC.
#
+# @/this, here, is the the normal-mode Mode object.
onKeypress = (event) ->
- return unless handlerStack.bubbleEvent('keypress', event)
-
keyChar = ""
# Ignore modifier keys by themselves.
@@ -363,23 +455,27 @@ onKeypress = (event) ->
# Enter insert mode when the user enables the native find interface.
if (keyChar == "f" && KeyboardUtils.isPrimaryModifierKey(event))
enterInsertModeWithoutShowingIndicator()
- return
+ return @stopBubblingAndTrue
if (keyChar)
if (findMode)
handleKeyCharForFindMode(keyChar)
DomUtils.suppressEvent(event)
+ return @stopBubblingAndTrue
else if (!isInsertMode() && !findMode)
if (isPassKey keyChar)
- return undefined
- if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar))
+ return @stopBubblingAndTrue
+ if currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar)
DomUtils.suppressEvent(event)
+ keyPort.postMessage({ keyChar:keyChar, frameId:frameId })
+ return @stopBubblingAndTrue
keyPort.postMessage({ keyChar:keyChar, frameId:frameId })
-onKeydown = (event) ->
- return unless handlerStack.bubbleEvent('keydown', event)
+ return @continueBubbling
+# @/this, here, is the the normal-mode Mode object.
+onKeydown = (event) ->
keyChar = ""
# handle special keys, and normal input keys with modifiers being pressed. don't handle shiftKey alone (to
@@ -408,46 +504,55 @@ onKeydown = (event) ->
keyChar = "<" + keyChar + ">"
if (isInsertMode() && KeyboardUtils.isEscape(event))
- # Note that we can't programmatically blur out of Flash embeds from Javascript.
- if (!isEmbed(event.srcElement))
+ if isEditable(event.srcElement) or isEmbed(event.srcElement)
# Remove focus so the user can't just get himself back into insert mode by typing in the same input
# box.
- if (isEditable(event.srcElement))
- event.srcElement.blur()
- exitInsertMode()
- DomUtils.suppressEvent event
- handledKeydownEvents.push event
+ # NOTE(smblott, 2014/12/22) Including embeds for .blur() etc. here is experimental. It appears to be
+ # 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.
+ event.srcElement.blur()
+ exitInsertMode()
+ DomUtils.suppressEvent event
+ KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (findMode)
if (KeyboardUtils.isEscape(event))
handleEscapeForFindMode()
DomUtils.suppressEvent event
- handledKeydownEvents.push event
+ KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey)
handleDeleteForFindMode()
DomUtils.suppressEvent event
- handledKeydownEvents.push event
+ KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (event.keyCode == keyCodes.enter)
handleEnterForFindMode()
DomUtils.suppressEvent event
- handledKeydownEvents.push event
+ KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (!modifiers)
DomUtils.suppressPropagation(event)
- handledKeydownEvents.push event
+ KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (isShowingHelpDialog && KeyboardUtils.isEscape(event))
hideHelpDialog()
DomUtils.suppressEvent event
- handledKeydownEvents.push event
+ KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (!isInsertMode() && !findMode)
if (keyChar)
if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar))
DomUtils.suppressEvent event
- handledKeydownEvents.push event
+ KeydownEvents.push event
+ keyPort.postMessage({ keyChar:keyChar, frameId:frameId })
+ return @stopBubblingAndTrue
keyPort.postMessage({ keyChar:keyChar, frameId:frameId })
@@ -468,23 +573,16 @@ onKeydown = (event) ->
(currentCompletionKeys.indexOf(KeyboardUtils.getKeyChar(event)) != -1 ||
isValidFirstKey(KeyboardUtils.getKeyChar(event))))
DomUtils.suppressPropagation(event)
- handledKeydownEvents.push event
+ KeydownEvents.push event
+ return @stopBubblingAndTrue
+ return @continueBubbling
+
+# @/this, here, is the the normal-mode Mode object.
onKeyup = (event) ->
- return unless handlerStack.bubbleEvent("keyup", event)
- return if isInsertMode()
-
- # Don't propagate the keyup to the underlying page if Vimium has handled it. See #733.
- for keydown, i in handledKeydownEvents
- if event.metaKey == keydown.metaKey and
- event.altKey == keydown.altKey and
- event.ctrlKey == keydown.ctrlKey and
- event.keyIdentifier == keydown.keyIdentifier and
- event.keyCode == keydown.keyCode
-
- handledKeydownEvents.splice i, 1
- DomUtils.suppressPropagation(event)
- break
+ return @continueBubbling unless KeydownEvents.pop event
+ DomUtils.suppressPropagation(event)
+ @stopBubblingAndTrue
checkIfEnabledForUrl = ->
url = window.location.toString()
@@ -496,8 +594,12 @@ checkIfEnabledForUrl = ->
else if (HUD.isReady())
# 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: response.isEnabledForUrl
+ passKeys: response.passKeys
-refreshCompletionKeys = (response) ->
+# Exported to window, but only for DOM tests.
+window.refreshCompletionKeys = (response) ->
if (response)
currentCompletionKeys = response.completionKeys
@@ -545,35 +647,21 @@ isEditable = (target) ->
focusableElements.indexOf(nodeName) >= 0
#
-# Enters insert mode and show an "Insert mode" message. Showing the UI is only useful when entering insert
-# mode manually by pressing "i". In most cases we do not show any UI (enterInsertModeWithoutShowingIndicator)
-#
-window.enterInsertMode = (target) ->
- enterInsertModeWithoutShowingIndicator(target)
- HUD.show("Insert mode")
-
-#
# We cannot count on 'focus' and 'blur' events to happen sequentially. For example, if blurring element A
# causes element B to come into focus, we may get "B focus" before "A blur". Thus we only leave insert mode
-# when the last editable element that came into focus -- which insertModeLock points to -- has been blurred.
-# If insert mode is entered manually (via pressing 'i'), then we set insertModeLock to 'undefined', and only
+# when the last editable element that came into focus -- which targetElement points to -- has been blurred.
+# If insert mode is entered manually (via pressing 'i'), then we set targetElement to 'undefined', and only
# leave insert mode when the user presses <ESC>.
# Note. This returns the truthiness of target, which is required by isInsertMode.
#
-enterInsertModeWithoutShowingIndicator = (target) -> insertModeLock = target
+enterInsertModeWithoutShowingIndicator = (target) ->
+ return # Disabled.
exitInsertMode = (target) ->
- if (target == undefined || insertModeLock == target)
- insertModeLock = null
- HUD.hide()
+ return # Disabled.
isInsertMode = ->
- return true if insertModeLock != null
- # Some sites (e.g. inbox.google.com) change the contentEditable attribute on the fly (see #1245); and
- # unfortunately, isEditable() is called *before* the change is made. Therefore, we need to re-check whether
- # the active element is contentEditable.
- document.activeElement and document.activeElement.isContentEditable and
- enterInsertModeWithoutShowingIndicator document.activeElement
+ return false # Disabled.
# should be called whenever rawQuery is modified.
updateFindModeQuery = ->
@@ -663,31 +751,53 @@ handleEnterForFindMode = ->
document.body.classList.add("vimiumFindMode")
settings.set("findModeRawQuery", findModeQuery.rawQuery)
-performFindInPlace = ->
- cachedScrollX = window.scrollX
- cachedScrollY = window.scrollY
+class FindMode extends Mode
+ constructor: ->
+ super
+ name: "find"
+ badge: "/"
+ exitOnEscape: true
+ exitOnClick: true
+
+ keydown: (event) =>
+ if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey
+ handleDeleteForFindMode()
+ @suppressEvent
+ else if event.keyCode == keyCodes.enter
+ handleEnterForFindMode()
+ @exit()
+ @suppressEvent
+ else
+ DomUtils.suppressPropagation(event)
+ handlerStack.stopBubblingAndFalse
- query = if findModeQuery.isRegex then getNextQueryFromRegexMatches(0) else findModeQuery.parsedQuery
+ keypress: (event) ->
+ handlerStack.neverContinueBubbling ->
+ if event.keyCode > 31
+ keyChar = String.fromCharCode event.charCode
+ handleKeyCharForFindMode keyChar if keyChar
- # Search backwards first to "free up" the current word as eligible for the real forward search. This allows
- # us to search in place without jumping around between matches as the query grows.
- executeFind(query, { backwards: true, caseSensitive: !findModeQuery.ignoreCase })
+ keyup: (event) => @suppressEvent
- # We need to restore the scroll position because we might've lost the right position by searching
- # backwards.
- window.scrollTo(cachedScrollX, cachedScrollY)
+ exit: (event) ->
+ super()
+ handleEscapeForFindMode() if event?.type == "keydown" and KeyboardUtils.isEscape event
+ handleEscapeForFindMode() if event?.type == "click"
+ if findModeQueryHasResults and event?.type != "click"
+ new PostFindMode
+performFindInPlace = ->
+ # Restore the selection. That way, we're always searching forward from the same place, so we find the right
+ # match as the user adds matching characters, or removes previously-matched characters. See #1434.
+ findModeRestoreSelection()
+ query = if findModeQuery.isRegex then getNextQueryFromRegexMatches(0) else findModeQuery.parsedQuery
findModeQueryHasResults = executeFind(query, { caseSensitive: !findModeQuery.ignoreCase })
# :options is an optional dict. valid parameters are 'caseSensitive' and 'backwards'.
executeFind = (query, options) ->
+ result = null
options = options || {}
- # rather hacky, but this is our way of signalling to the insertMode listener not to react to the focus
- # changes that find() induces.
- oldFindMode = findMode
- findMode = true
-
document.body.classList.add("vimiumFindMode")
# prevent find from matching its own search query in the HUD
@@ -699,7 +809,13 @@ executeFind = (query, options) ->
-> document.addEventListener("selectionchange", restoreDefaultSelectionHighlight, true)
0)
- findMode = oldFindMode
+ # We are either in normal mode ("n"), or find mode ("/"). We are not in insert mode. Nevertheless, if a
+ # 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()
+
# we need to save the anchor node here because <esc> seems to nullify it, regardless of whether we do
# preventDefault()
findModeAnchorNode = document.getSelection().anchorNode
@@ -712,13 +828,6 @@ focusFoundLink = ->
link = getLinkFromSelection()
link.focus() if link
-isDOMDescendant = (parent, child) ->
- node = child
- while (node != null)
- return true if (node == parent)
- node = node.parentNode
- false
-
selectFoundInputElement = ->
# if the found text is in an input element, getSelection().anchorNode will be null, so we use activeElement
# instead. however, since the last focused element might not be the one currently pointed to by find (e.g.
@@ -726,7 +835,7 @@ selectFoundInputElement = ->
# heuristic of checking that the last anchor node is an ancestor of our element.
if (findModeQueryHasResults && document.activeElement &&
DomUtils.isSelectable(document.activeElement) &&
- isDOMDescendant(findModeAnchorNode, document.activeElement))
+ DomUtils.isDOMDescendant(findModeAnchorNode, document.activeElement))
DomUtils.simulateSelect(document.activeElement)
# the element has already received focus via find(), so invoke insert mode manually
enterInsertModeWithoutShowingIndicator(document.activeElement)
@@ -757,27 +866,11 @@ findAndFocus = (backwards) ->
findModeQueryHasResults =
executeFind(query, { backwards: backwards, caseSensitive: !findModeQuery.ignoreCase })
- if (!findModeQueryHasResults)
+ if findModeQueryHasResults
+ focusFoundLink()
+ new PostFindMode() if findModeQueryHasResults
+ else
HUD.showForDuration("No matches for '" + findModeQuery.rawQuery + "'", 1000)
- return
-
- # if we have found an input element via 'n', pressing <esc> immediately afterwards sends us into insert
- # mode
- elementCanTakeInput = document.activeElement &&
- DomUtils.isSelectable(document.activeElement) &&
- isDOMDescendant(findModeAnchorNode, document.activeElement)
- if (elementCanTakeInput)
- handlerStack.push({
- keydown: (event) ->
- @remove()
- if (KeyboardUtils.isEscape(event))
- DomUtils.simulateSelect(document.activeElement)
- enterInsertModeWithoutShowingIndicator(document.activeElement)
- return false # we have "consumed" this event, so do not propagate
- return true
- })
-
- focusFoundLink()
window.performFind = -> findAndFocus()
@@ -890,13 +983,33 @@ showFindModeHUDForQuery = ->
else
HUD.show("/" + findModeQuery.rawQuery + " (No Matches)")
+getCurrentRange = ->
+ selection = getSelection()
+ if selection.type == "None"
+ range = document.createRange()
+ range.setStart document.body, 0
+ range.setEnd document.body, 0
+ range
+ else
+ selection.collapseToStart() if selection.type == "Range"
+ selection.getRangeAt 0
+
+findModeSaveSelection = ->
+ findModeInitialRange = getCurrentRange()
+
+findModeRestoreSelection = (range = findModeInitialRange) ->
+ selection = getSelection()
+ selection.removeAllRanges()
+ selection.addRange range
+
window.enterFindMode = ->
+ # Save the selection, so performFindInPlace can restore it.
+ findModeSaveSelection()
findModeQuery = { rawQuery: "" }
- findMode = true
HUD.show("/")
+ new FindMode()
exitFindMode = ->
- findMode = false
HUD.hide()
window.showHelpDialog = (html, fid) ->
@@ -986,10 +1099,10 @@ HUD =
HUD.displayElement().style.display = ""
showUpgradeNotification: (version) ->
- HUD.upgradeNotificationElement().innerHTML = "Vimium has been updated to
- <a class='vimiumReset'
- href='https://chrome.google.com/extensions/detail/dbepggeogbaibhgnhhndojpepiihcmeb'>
- #{version}</a>.<a class='vimiumReset close-button' href='#'>&times;</a>"
+ 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) ->
@@ -1067,10 +1180,44 @@ Tween =
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.
+ #
+ cursorHideStyle: null
+ isScrolling: false
+
+ onScroll: (event) ->
+ CursorHider.isScrolling = true
+ unless CursorHider.cursorHideStyle.parentElement
+ document.head.appendChild CursorHider.cursorHideStyle
+
+ onMouseMove: (event) ->
+ if CursorHider.cursorHideStyle.parentElement and not CursorHider.isScrolling
+ CursorHider.cursorHideStyle.remove()
+ CursorHider.isScrolling = false
+
+ init: ->
+ # Temporarily disabled pending consideration of #1359 (in particular, whether cursor hiding is too fragile
+ # as to provide a consistent UX).
+ return
+
+ # Disable cursor hiding for Chrome versions less than 39.0.2171.71 due to a suspected browser error.
+ # See #1345 and #1348.
+ return unless Utils.haveChromeVersion "39.0.2171.71"
+
+ @cursorHideStyle = document.createElement("style")
+ @cursorHideStyle.innerHTML = """
+ body * {pointer-events: none !important; cursor: none !important;}
+ body, html {cursor: none !important;}
+ """
+ window.addEventListener "mousemove", @onMouseMove
+ window.addEventListener "scroll", @onScroll
+
initializePreDomReady()
-window.addEventListener("DOMContentLoaded", registerFrame)
-window.addEventListener("unload", unregisterFrame)
-window.addEventListener("DOMContentLoaded", initializeOnDomReady)
+DomUtils.documentReady initializeOnDomReady
+DomUtils.documentReady registerFrame
+window.addEventListener "unload", unregisterFrame
window.onbeforeunload = ->
chrome.runtime.sendMessage(
diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee
index f1d2ccc5..6381fd7f 100644
--- a/content_scripts/vomnibar.coffee
+++ b/content_scripts/vomnibar.coffee
@@ -1,245 +1,51 @@
-Vomnibar =
- vomnibarUI: null # the dialog instance for this window
- completers: {}
-
- getCompleter: (name) ->
- if (!(name of @completers))
- @completers[name] = new BackgroundCompleter(name)
- @completers[name]
-
- #
- # Activate the Vomnibox.
- #
- activateWithCompleter: (completerName, refreshInterval, initialQueryValue, selectFirstResult, forceNewTab) ->
- completer = @getCompleter(completerName)
- @vomnibarUI = new VomnibarUI() unless @vomnibarUI
- completer.refresh()
- @vomnibarUI.setInitialSelectionValue(if selectFirstResult then 0 else -1)
- @vomnibarUI.setCompleter(completer)
- @vomnibarUI.setRefreshInterval(refreshInterval)
- @vomnibarUI.setForceNewTab(forceNewTab)
- @vomnibarUI.show()
- if (initialQueryValue)
- @vomnibarUI.setQuery(initialQueryValue)
- @vomnibarUI.update()
-
- activate: -> @activateWithCompleter("omni", 100)
- activateInNewTab: -> @activateWithCompleter("omni", 100, null, false, true)
- activateTabSelection: -> @activateWithCompleter("tabs", 0, null, true)
- activateBookmarks: -> @activateWithCompleter("bookmarks", 0, null, true)
- activateBookmarksInNewTab: -> @activateWithCompleter("bookmarks", 0, null, true, true)
- activateEditUrl: -> @activateWithCompleter("omni", 100, window.location.href)
- activateEditUrlInNewTab: -> @activateWithCompleter("omni", 100, window.location.href, false, true)
- getUI: -> @vomnibarUI
-
-
-class VomnibarUI
- constructor: ->
- @refreshInterval = 0
- @initDom()
-
- setQuery: (query) -> @input.value = query
-
- setInitialSelectionValue: (initialSelectionValue) ->
- @initialSelectionValue = initialSelectionValue
-
- setCompleter: (completer) ->
- @completer = completer
- @reset()
-
- setRefreshInterval: (refreshInterval) -> @refreshInterval = refreshInterval
-
- setForceNewTab: (forceNewTab) -> @forceNewTab = forceNewTab
-
- show: ->
- @box.style.display = "block"
- @input.focus()
- @handlerId = handlerStack.push keydown: @onKeydown.bind @
-
- hide: ->
- @box.style.display = "none"
- @completionList.style.display = "none"
- @input.blur()
- handlerStack.remove @handlerId
-
- reset: ->
- @input.value = ""
- @updateTimer = null
- @completions = []
- @selection = @initialSelectionValue
- @update(true)
-
- updateSelection: ->
- # We have taken the option to add some global state here (previousCompletionType) to tell if a search
- # item has just appeared or disappeared, if that happens we either set the initialSelectionValue to 0 or 1
- # I feel that this approach is cleaner than bubbling the state up from the suggestion level
- # so we just inspect it afterwards
- if @completions[0]
- if @previousCompletionType != "search" && @completions[0].type == "search"
- @selection = 0
- else if @previousCompletionType == "search" && @completions[0].type != "search"
- @selection = -1
- for i in [0...@completionList.children.length]
- @completionList.children[i].className = (if i == @selection then "vomnibarSelected" else "")
- @previousCompletionType = @completions[0].type if @completions[0]
-
- #
- # 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.
- #
- actionFromKeyEvent: (event) ->
- key = KeyboardUtils.getKeyChar(event)
- if (KeyboardUtils.isEscape(event))
- return "dismiss"
- else if (key == "up" ||
- (event.shiftKey && event.keyCode == keyCodes.tab) ||
- (event.ctrlKey && (key == "k" || key == "p")))
- return "up"
- 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"
-
- onKeydown: (event) ->
- 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 == "up")
- @selection -= 1
- @selection = @completions.length - 1 if @selection < @initialSelectionValue
- @input.value = @completions[@selection].url
- @updateSelection()
- else if (action == "down")
- @selection += 1
- @selection = @initialSelectionValue if @selection == @completions.length
- @input.value = @completions[@selection].url
- @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)
- query = @input.value.trim()
- # <Enter> on an empty vomnibar is a no-op.
- return unless 0 < query.length
- @hide()
- chrome.runtime.sendMessage({
- handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab"
- url: query })
- else
- @update true, =>
- # Shift+Enter will open the result in a new tab instead of the current tab.
- @completions[@selection].performAction(openInNewTab)
- @hide()
-
- # It seems like we have to manually suppress the event here and still return true.
- DomUtils.suppressPropagation(event)
- 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 "none"
- @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)
- @updateTimer = null
- @refreshInterval)
-
- initDom: ->
- @box = Utils.createElementFromHtml(
- """
- <div id="vomnibar" class="vimiumReset">
- <div class="vimiumReset vomnibarSearchArea">
- <input type="text" class="vimiumReset">
- </div>
- <ul class="vimiumReset"></ul>
- </div>
- """)
- @box.style.display = "none"
- document.body.appendChild(@box)
-
- @input = document.querySelector("#vomnibar input")
- @input.addEventListener "input", => @update()
- @completionList = document.querySelector("#vomnibar ul")
- @completionList.style.display = "none"
-
#
-# Sends filter and refresh requests to a Vomnibox completer on the background page.
+# This wraps the vomnibar iframe, which we inject into the page to provide the vomnibar.
#
-class BackgroundCompleter
- # - name: The background page completer that you want to interface with. Either "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.
- #
- completionActions:
- navigateToUrl: (url, openInNewTab) ->
- # If the URL is a bookmarklet prefixed with javascript:, we shouldn't open that in a new tab.
- if url.startsWith "javascript:"
- script = document.createElement 'script'
- script.textContent = decodeURIComponent(url["javascript:".length..])
- (document.head || document.documentElement).appendChild script
- else
- chrome.runtime.sendMessage(
- handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab"
- url: url,
- selected: openInNewTab)
-
- switchToTab: (tabId) -> chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: tabId })
+Vomnibar =
+ vomnibarUI: null
+
+ activate: -> @open {completer:"omni"}
+ activateInNewTab: -> @open {
+ completer: "omni"
+ selectFirst: false
+ newTab: true
+ }
+ activateTabSelection: -> @open {
+ completer: "tabs"
+ selectFirst: true
+ }
+ activateBookmarks: -> @open {
+ completer: "bookmarks"
+ selectFirst: true
+ }
+ activateBookmarksInNewTab: -> @open {
+ completer: "bookmarks"
+ selectFirst: true
+ newTab: true
+ }
+ activateEditUrl: -> @open {
+ completer: "omni"
+ selectFirst: false
+ query: window.location.href
+ }
+ activateEditUrlInNewTab: -> @open {
+ completer: "omni"
+ selectFirst: false
+ query: window.location.href
+ newTab: true
+ }
+
+ init: ->
+ unless @vomnibarUI?
+ @vomnibarUI = new UIComponent "pages/vomnibar.html", "vomnibarFrame", (event) =>
+ @vomnibarUI.hide() if event.data == "hide"
+
+ # 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
root = exports ? window
root.Vomnibar = Vomnibar
diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee
index 21018049..4f36e395 100644
--- a/lib/dom_utils.coffee
+++ b/lib/dom_utils.coffee
@@ -2,10 +2,11 @@ DomUtils =
#
# Runs :callback if the DOM has loaded, otherwise runs it on load
#
- documentReady: do ->
- loaded = false
- window.addEventListener("DOMContentLoaded", -> loaded = true)
- (callback) -> if loaded then callback() else window.addEventListener("DOMContentLoaded", callback)
+ documentReady: (func) ->
+ if document.readyState == "loading"
+ window.addEventListener "DOMContentLoaded", func
+ else
+ func()
#
# Adds a list of elements to a page.
@@ -33,47 +34,24 @@ DomUtils =
makeXPath: (elementArray) ->
xpath = []
for element in elementArray
- xpath.push("//" + element, "//xhtml:" + element)
+ xpath.push(".//" + element, ".//xhtml:" + element)
xpath.join(" | ")
+ # Evaluates an XPath on the whole document, or on the contents of the fullscreen element if an element is
+ # fullscreen.
evaluateXPath: (xpath, resultType) ->
+ contextNode =
+ if document.webkitIsFullScreen then document.webkitFullscreenElement else document.documentElement
namespaceResolver = (namespace) ->
if (namespace == "xhtml") then "http://www.w3.org/1999/xhtml" else null
- document.evaluate(xpath, document.documentElement, namespaceResolver, resultType, null)
+ document.evaluate(xpath, contextNode, namespaceResolver, resultType, null)
#
# Returns the first visible clientRect of an element if it exists. Otherwise it returns null.
#
getVisibleClientRect: (element) ->
# Note: this call will be expensive if we modify the DOM in between calls.
- clientRects = ({
- top: clientRect.top, right: clientRect.right, bottom: clientRect.bottom, left: clientRect.left,
- width: clientRect.width, height: clientRect.height
- } for clientRect in element.getClientRects())
-
- for clientRect in clientRects
- if (clientRect.top < 0)
- clientRect.height += clientRect.top
- clientRect.top = 0
-
- if (clientRect.left < 0)
- clientRect.width += clientRect.left
- clientRect.left = 0
-
- if (clientRect.top >= window.innerHeight - 4 || clientRect.left >= window.innerWidth - 4)
- continue
-
- if (clientRect.width < 3 || clientRect.height < 3)
- continue
-
- # eliminate invisible elements (see test_harnesses/visibility_test.html)
- computedStyle = window.getComputedStyle(element, null)
- if (computedStyle.getPropertyValue('visibility') != 'visible' ||
- computedStyle.getPropertyValue('display') == 'none' ||
- computedStyle.getPropertyValue('opacity') == '0')
- continue
-
- return clientRect
+ clientRects = (Rect.copy clientRect for clientRect in element.getClientRects())
for clientRect in clientRects
# If the link has zero dimensions, it may be wrapping visible
@@ -86,22 +64,132 @@ DomUtils =
continue if (computedStyle.getPropertyValue('float') == 'none' &&
computedStyle.getPropertyValue('position') != 'absolute')
childClientRect = @getVisibleClientRect(child)
- continue if (childClientRect == null)
+ continue if childClientRect == null or childClientRect.width < 3 or childClientRect.height < 3
return childClientRect
+
+ else
+ clientRect = @cropRectToVisible clientRect
+
+ continue if clientRect == null or clientRect.width < 3 or clientRect.height < 3
+
+ # eliminate invisible elements (see test_harnesses/visibility_test.html)
+ computedStyle = window.getComputedStyle(element, null)
+ if (computedStyle.getPropertyValue('visibility') != 'visible' ||
+ computedStyle.getPropertyValue('display') == 'none')
+ continue
+
+ return clientRect
+
null
#
- # Selectable means the element has a text caret; this is not the same as "focusable".
+ # Bounds the rect by the current viewport dimensions. If the rect is offscreen or has a height or width < 3
+ # then null is returned instead of a rect.
+ #
+ cropRectToVisible: (rect) ->
+ boundedRect = Rect.create(
+ Math.max(rect.left, 0)
+ Math.max(rect.top, 0)
+ rect.right
+ rect.bottom
+ )
+ if boundedRect.top >= window.innerHeight - 4 or boundedRect.left >= window.innerWidth - 4
+ null
+ else
+ boundedRect
+
+ #
+ # Get the client rects for the <area> elements in a <map> based on the position of the <img> element using
+ # the map. Returns an array of rects.
+ #
+ getClientRectsForAreas: (imgClientRect, areas) ->
+ rects = []
+ for area in areas
+ coords = area.coords.split(",").map((coord) -> parseInt(coord, 10))
+ shape = area.shape.toLowerCase()
+ if shape in ["rect", "rectangle"] # "rectangle" is an IE non-standard.
+ [x1, y1, x2, y2] = coords
+ else if shape in ["circle", "circ"] # "circ" is an IE non-standard.
+ [x, y, r] = coords
+ diff = r / Math.sqrt 2 # Gives us an inner square
+ x1 = x - diff
+ x2 = x + diff
+ y1 = y - diff
+ y2 = y + diff
+ else if shape == "default"
+ [x1, y1, x2, y2] = [0, 0, imgClientRect.width, imgClientRect.height]
+ else
+ # Just consider the rectangle surrounding the first two points in a polygon. It's possible to do
+ # something more sophisticated, but likely not worth the effort.
+ [x1, y1, x2, y2] = coords
+
+ rect = Rect.translate (Rect.create x1, y1, x2, y2), imgClientRect.left, imgClientRect.top
+ rect = @cropRectToVisible rect
+
+ rects.push {element: area, rect: rect} if rect and not isNaN rect.top
+ rects
+
+ #
+ # Selectable means that we should use the simulateSelect method to activate the element instead of a click.
+ #
+ # The html5 input types that should use simulateSelect are:
+ # ["date", "datetime", "datetime-local", "email", "month", "number", "password", "range", "search",
+ # "tel", "text", "time", "url", "week"]
+ # An unknown type will be treated the same as "text", in the same way that the browser does.
#
isSelectable: (element) ->
- selectableTypes = ["search", "text", "password"]
- (element.nodeName.toLowerCase() == "input" && selectableTypes.indexOf(element.type) >= 0) ||
- element.nodeName.toLowerCase() == "textarea"
+ unselectableTypes = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "reset", "submit"]
+ (element.nodeName.toLowerCase() == "input" && unselectableTypes.indexOf(element.type) == -1) ||
+ element.nodeName.toLowerCase() == "textarea" || element.isContentEditable
+
+ # Input or text elements are considered focusable and able to receieve their own keyboard events, and will
+ # enter insert mode if focused. Also note that the "contentEditable" attribute can be set on any element
+ # which makes it a rich text editor, like the notes on jjot.com.
+ isEditable: (element) ->
+ return true if element.isContentEditable
+ nodeName = element.nodeName?.toLowerCase()
+ # Use a blacklist instead of a whitelist because new form controls are still being implemented for html5.
+ if nodeName == "input" and element.type not in ["radio", "checkbox"]
+ return true
+ nodeName in ["textarea", "select"]
+
+ # Embedded elements like Flash and quicktime players can obtain focus.
+ isEmbed: (element) ->
+ element.nodeName?.toLowerCase() in ["embed", "object"]
+
+ isFocusable: (element) ->
+ @isEditable(element) or @isEmbed element
+
+ isDOMDescendant: (parent, child) ->
+ node = child
+ while (node != null)
+ return true if (node == parent)
+ node = node.parentNode
+ false
+
+ # True if element contains the active selection range.
+ isSelected: (element) ->
+ if element.isContentEditable
+ node = document.getSelection()?.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
simulateSelect: (element) ->
- element.focus()
- # When focusing a textbox, put the selection caret at the end of the textbox's contents.
- element.setSelectionRange(element.value.length, element.value.length)
+ # If element is already active, then we don't move the selection. However, we also won't get a new focus
+ # event. So, instead we pretend (to any active modes which care, e.g. PostFindMode) that element has been
+ # clicked.
+ if element == document.activeElement and DomUtils.isEditable document.activeElement
+ 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)
simulateClick: (element, modifiers) ->
modifiers ||= {}
@@ -134,5 +222,14 @@ DomUtils =
event.preventDefault()
@suppressPropagation(event)
+ # Suppress the next keyup event for Escape.
+ suppressKeyupAfterEscape: (handlerStack) ->
+ handlerStack.push
+ _name: "dom_utils/suppressKeyupAfterEscape"
+ keyup: (event) ->
+ return true unless KeyboardUtils.isEscape event
+ @remove()
+ false
+
root = exports ? window
root.DomUtils = DomUtils
diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee
index 858f2ec9..76d835b7 100644
--- a/lib/handler_stack.coffee
+++ b/lib/handler_stack.coffee
@@ -1,37 +1,99 @@
root = exports ? window
-class root.HandlerStack
+class HandlerStack
constructor: ->
+ @debug = false
+ @eventNumber = 0
@stack = []
@counter = 0
- genId: -> @counter = ++@counter & 0xffff
+ # A handler should return this value to immediately discontinue bubbling and pass the event on to the
+ # underlying page.
+ @stopBubblingAndTrue = new Object()
- # Adds a handler to the stack. Returns a unique ID for that handler that can be used to remove it later.
+ # A handler should return this value to indicate that the event has been consumed, and no further
+ # processing should take place. The event does not propagate to the underlying page.
+ @stopBubblingAndFalse = new Object()
+
+ # A handler should return this value to indicate that bubbling should be restarted. Typically, this is
+ # used when, while bubbling an event, a new mode is pushed onto the stack.
+ @restartBubbling = new Object()
+
+ # Adds a handler to the top of the stack. Returns a unique ID for that handler that can be used to remove it
+ # later.
push: (handler) ->
- handler.id = @genId()
+ handler._name ||= "anon-#{@counter}"
@stack.push handler
- handler.id
+ handler.id = ++@counter
+
+ # As above, except the new handler is added to the bottom of the stack.
+ unshift: (handler) ->
+ handler._name ||= "anon-#{@counter}"
+ handler._name += "/unshift"
+ @stack.unshift handler
+ handler.id = ++@counter
- # Called whenever we receive a key event. Each individual handler has the option to stop the event's
- # propagation by returning a falsy value.
+ # Called whenever we receive a key or other event. Each individual handler has the option to stop the
+ # event's propagation by returning a falsy value, or stop bubbling by returning @stopBubblingAndFalse or
+ # @stopBubblingAndTrue.
bubbleEvent: (type, event) ->
- for i in [(@stack.length - 1)..0] by -1
- handler = @stack[i]
- # We need to check for existence of handler because the last function call may have caused the release
- # of more than one handler.
- if handler && handler[type]
+ @eventNumber += 1
+ # We take a copy of the array in order to avoid interference from concurrent removes (for example, to
+ # avoid calling the same handler twice, because elements have been spliced out of the array by remove).
+ for handler in @stack[..].reverse()
+ # A handler may have been removed (handler.id == null), so check.
+ if handler?.id and handler[type]
@currentId = handler.id
- passThrough = handler[type].call(@, event)
- if not passThrough
- DomUtils.suppressEvent(event)
+ result = handler[type].call @, event
+ @logResult type, event, handler, result if @debug
+ if not result
+ DomUtils.suppressEvent event if @isChromeEvent event
return false
+ return true if result == @stopBubblingAndTrue
+ return false if result == @stopBubblingAndFalse
+ return @bubbleEvent type, event if result == @restartBubbling
true
remove: (id = @currentId) ->
for i in [(@stack.length - 1)..0] by -1
handler = @stack[i]
if handler.id == id
+ # Mark the handler as removed.
+ handler.id = null
@stack.splice(i, 1)
break
+
+ # The handler stack handles chrome events (which may need to be suppressed) and internal (pseudo) events.
+ # This checks whether the event at hand is a chrome event.
+ isChromeEvent: (event) ->
+ event?.preventDefault? or event?.stopImmediatePropagation?
+
+ # Convenience wrappers. Handlers must return an approriate value. These are wrappers which handlers can
+ # use to always return the same value. This then means that the handler itself can be implemented without
+ # regard to its return value.
+ alwaysContinueBubbling: (handler) ->
+ handler()
+ true
+
+ neverContinueBubbling: (handler) ->
+ handler()
+ false
+
+ # Debugging.
+ logResult: (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" ]
+ label =
+ switch result
+ when @stopBubblingAndTrue then "stop/true"
+ when @stopBubblingAndFalse then "stop/false"
+ when @restartBubbling then "rebubble"
+ when true then "continue"
+ label ||= if result then "continue/truthy" else "suppress"
+ console.log "#{@eventNumber}", type, handler._name, label
+
+root.HandlerStack = HandlerStack
+root.handlerStack = new HandlerStack()
diff --git a/lib/keyboard_utils.coffee b/lib/keyboard_utils.coffee
index d2a843f9..30d99656 100644
--- a/lib/keyboard_utils.coffee
+++ b/lib/keyboard_utils.coffee
@@ -55,6 +55,12 @@ KeyboardUtils =
# c-[ is mapped to ESC in Vim by default.
(event.keyCode == @keyCodes.ESC) || (event.ctrlKey && @getKeyChar(event) == '[')
+ # TODO. This is probably a poor way of detecting printable characters. However, it shouldn't incorrectly
+ # identify any of chrome's own keyboard shortcuts as printable.
+ isPrintable: (event) ->
+ return false if event.metaKey or event.ctrlKey or event.altKey
+ @getKeyChar(event)?.length == 1
+
KeyboardUtils.init()
root = exports ? window
diff --git a/lib/rect.coffee b/lib/rect.coffee
new file mode 100644
index 00000000..adc1fc36
--- /dev/null
+++ b/lib/rect.coffee
@@ -0,0 +1,82 @@
+# Commands for manipulating rects.
+Rect =
+ # Create a rect given the top left and bottom right corners.
+ create: (x1, y1, x2, y2) ->
+ bottom: y2
+ top: y1
+ left: x1
+ right: x2
+ width: x2 - x1
+ height: y2 - y1
+
+ copy: (rect) ->
+ bottom: rect.bottom
+ top: rect.top
+ left: rect.left
+ right: rect.right
+ width: rect.width
+ height: rect.height
+
+ # Translate a rect by x horizontally and y vertically.
+ translate: (rect, x = 0, y = 0) ->
+ bottom: rect.bottom + y
+ top: rect.top + y
+ left: rect.left + x
+ right: rect.right + x
+ width: rect.width
+ height: rect.height
+
+ # Subtract rect2 from rect1, returning an array of rects which are in rect1 but not rect2.
+ subtract: (rect1, rect2) ->
+ # Bound rect2 by rect1
+ rect2 = @create(
+ Math.max(rect1.left, rect2.left),
+ Math.max(rect1.top, rect2.top),
+ Math.min(rect1.right, rect2.right),
+ Math.min(rect1.bottom, rect2.bottom)
+ )
+
+ # If bounding rect2 has made the width or height negative, rect1 does not contain rect2.
+ return [Rect.copy rect1] if rect2.width < 0 or rect2.height < 0
+
+ #
+ # All the possible rects, in the order
+ # +-+-+-+
+ # |1|2|3|
+ # +-+-+-+
+ # |4| |5|
+ # +-+-+-+
+ # |6|7|8|
+ # +-+-+-+
+ # where the outer rectangle is rect1 and the inner rectangle is rect 2. Note that the rects may be of
+ # width or height 0.
+ #
+ rects = [
+ # Top row.
+ @create rect1.left, rect1.top, rect2.left, rect2.top
+ @create rect2.left, rect1.top, rect2.right, rect2.top
+ @create rect2.right, rect1.top, rect1.right, rect2.top
+ # Middle row.
+ @create rect1.left, rect2.top, rect2.left, rect2.bottom
+ @create rect2.right, rect2.top, rect1.right, rect2.bottom
+ # Bottom row.
+ @create rect1.left, rect2.bottom, rect2.left, rect1.bottom
+ @create rect2.left, rect2.bottom, rect2.right, rect1.bottom
+ @create rect2.right, rect2.bottom, rect1.right, rect1.bottom
+ ]
+
+ rects.filter (rect) -> rect.height > 0 and rect.width > 0
+
+ contains: (rect1, rect2) ->
+ rect1.right > rect2.left and
+ rect1.left < rect2.right and
+ rect1.bottom > rect2.top and
+ rect1.top < rect2.bottom
+
+ equals: (rect1, rect2) ->
+ for property in ["top", "bottom", "left", "right", "width", "height"]
+ return false if rect1[property] != rect2[property]
+ true
+
+root = exports ? window
+root.Rect = Rect
diff --git a/lib/utils.coffee b/lib/utils.coffee
index b7f8731a..661f7e84 100644
--- a/lib/utils.coffee
+++ b/lib/utils.coffee
@@ -26,11 +26,10 @@ Utils =
-> id += 1
hasChromePrefix: do ->
- chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:" ]
+ chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:", "javascript:" ]
(url) ->
- if 0 < url.indexOf ":"
- for prefix in chromePrefixes
- return true if url.startsWith prefix
+ for prefix in chromePrefixes
+ return true if url.startsWith prefix
false
hasFullUrlPrefix: do ->
@@ -88,11 +87,17 @@ Utils =
# Fallback: no URL
return false
+ # Map a search query to its URL encoded form. The query may be either a string or an array of strings.
+ # E.g. "BBC Sport" -> "BBC+Sport".
+ createSearchQuery: (query) ->
+ 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,
- # but it is not clear if/how that is possible
- Settings.get("searchUrl") + encodeURIComponent(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
# 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.
@@ -110,6 +115,12 @@ Utils =
# detects both literals and dynamically created strings
isString: (obj) -> typeof obj == 'string' or obj instanceof String
+ # Transform "zjkjkabz" into "abjkz".
+ distinctCharacters: (str) ->
+ unique = ""
+ for char in str.split("").sort()
+ unique += char unless 0 <= unique.indexOf char
+ unique
# Compares two version strings (e.g. "1.1" and "1.5") and returns
# -1 if versionA is < versionB, 0 if they're equal, and 1 if versionA is > versionB.
@@ -125,6 +136,11 @@ Utils =
return 1
0
+ # True if the current Chrome version is at least the required version.
+ haveChromeVersion: (required) ->
+ chromeVersion = navigator.appVersion.match(/Chrome\/(.*?) /)?[1]
+ chromeVersion and 0 <= Utils.compareVersions chromeVersion, required
+
# Zip two (or more) arrays:
# - Utils.zip([ [a,b], [1,2] ]) returns [ [a,1], [b,2] ]
# - Length of result is `arrays[0].length`.
diff --git a/manifest.json b/manifest.json
index 3792458b..a04d8c0e 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "Vimium",
- "version": "1.45",
+ "version": "1.49",
"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",
@@ -35,12 +35,19 @@
"js": ["lib/utils.js",
"lib/keyboard_utils.js",
"lib/dom_utils.js",
+ "lib/rect.js",
"lib/handler_stack.js",
"lib/clipboard.js",
+ "content_scripts/ui_component.js",
"content_scripts/link_hints.js",
"content_scripts/vomnibar.js",
"content_scripts/scroller.js",
"content_scripts/marks.js",
+ "content_scripts/mode.js",
+ "content_scripts/mode_insert.js",
+ "content_scripts/mode_passkeys.js",
+ "content_scripts/mode_find.js",
+ "content_scripts/mode_visual.js",
"content_scripts/vimium_frontend.js"
],
"css": ["content_scripts/vimium.css"],
@@ -57,5 +64,8 @@
"browser_action": {
"default_icon": "icons/browser_action_disabled.png",
"default_popup": "pages/popup.html"
- }
+ },
+ "web_accessible_resources": [
+ "pages/vomnibar.html"
+ ]
}
diff --git a/pages/exclusions.html b/pages/exclusions.html
new file mode 100644
index 00000000..b09f2895
--- /dev/null
+++ b/pages/exclusions.html
@@ -0,0 +1,13 @@
+<table id="exclusionRules">
+ <tr>
+ <td><span class="exclusionHeaderText">Patterns</span></td>
+ <td><span class="exclusionHeaderText">Keys</span></td>
+ </tr>
+</table>
+<template id="exclusionRuleTemplate">
+ <tr class="exclusionRuleTemplateInstance">
+ <td><input/ type="text" class="pattern" spellcheck="false" placeholder="URL pattern"></td>
+ <td class="exclusionRulePassKeys"><input/ type="text" class="passKeys" spellcheck="false" placeholder="Exclude keys"></td>
+ <td class="exclusionRemoveButton"><input/ type="button" class="exclusionRemoveButtonButton" value="&#x2716;"></td>
+ </tr>
+</template>
diff --git a/pages/options.coffee b/pages/options.coffee
index f5968eb9..93c9b503 100644
--- a/pages/options.coffee
+++ b/pages/options.coffee
@@ -1,6 +1,8 @@
$ = (id) -> document.getElementById id
+bgUtils = chrome.extension.getBackgroundPage().Utils
bgSettings = chrome.extension.getBackgroundPage().Settings
+bgExclusions = chrome.extension.getBackgroundPage().Exclusions
#
# Class hierarchy for various types of option.
@@ -11,10 +13,9 @@ class Option
# Static. Array of all options.
@all = []
- constructor: (field,enableSaveButton) ->
- @field = field
+ constructor: (@field,@onUpdated) ->
@element = $(@field)
- @element.addEventListener "change", enableSaveButton
+ @element.addEventListener "change", @onUpdated
@fetch()
Option.all.push @
@@ -41,14 +42,6 @@ class Option
# Static method.
@saveOptions: ->
Option.all.map (option) -> option.save()
- $("saveOptions").disabled = true
-
- # Used by text options. <ctrl-Enter> saves all options.
- activateCtrlEnterListener: (element) ->
- element.addEventListener "keyup", (event) ->
- if event.ctrlKey and event.keyCode == 13
- element.blur()
- Option.saveOptions()
# Abstract method; only implemented in sub-classes.
# Populate the option's DOM element (@element) with the setting's current value.
@@ -63,18 +56,16 @@ class NumberOption extends Option
readValueFromElement: -> parseFloat @element.value
class TextOption extends Option
- constructor: (field,enableSaveButton) ->
- super(field,enableSaveButton)
- @element.addEventListener "input", enableSaveButton
- @activateCtrlEnterListener @element
+ constructor: (args...) ->
+ super(args...)
+ @element.addEventListener "input", @onUpdated
populateElement: (value) -> @element.value = value
readValueFromElement: -> @element.value.trim()
class NonEmptyTextOption extends Option
- constructor: (field,enableSaveButton) ->
- super(field,enableSaveButton)
- @element.addEventListener "input", enableSaveButton
- @activateCtrlEnterListener @element
+ constructor: (args...) ->
+ super(args...)
+ @element.addEventListener "input", @onUpdated
populateElement: (value) -> @element.value = value
# If the new value is not empty, then return it. Otherwise, restore the default value.
@@ -88,22 +79,24 @@ class ExclusionRulesOption extends Option
constructor: (args...) ->
super(args...)
$("exclusionAddButton").addEventListener "click", (event) =>
- @appendRule { pattern: "", passKeys: "" }
- @maintainExclusionMargin()
- # Focus the pattern element in the new rule.
- @element.children[@element.children.length-1].children[0].children[0].focus()
- # Scroll the new rule into view.
+ @addRule()
+
+ # Add a new rule, focus its pattern, scroll it into view, and return the newly-added element. On the
+ # options page, there is no current URL, so there is no initial pattern. This is the default. On the popup
+ # page (see ExclusionRulesOnPopupOption), the pattern is pre-populated based on the current tab's URL.
+ addRule: (pattern="") ->
+ element = @appendRule { pattern: pattern, passKeys: "" }
+ @getPattern(element).focus()
exclusionScrollBox = $("exclusionScrollBox")
exclusionScrollBox.scrollTop = exclusionScrollBox.scrollHeight
+ @onUpdated()
+ element
populateElement: (rules) ->
- while @element.firstChild
- @element.removeChild @element.firstChild
for rule in rules
@appendRule rule
- @maintainExclusionMargin()
- # Append a row for a new rule.
+ # Append a row for a new rule. Return the newly-added element.
appendRule: (rule) ->
content = document.querySelector('#exclusionRuleTemplate').content
row = document.importNode content, true
@@ -111,25 +104,22 @@ class ExclusionRulesOption extends Option
for field in ["pattern", "passKeys"]
element = row.querySelector ".#{field}"
element.value = rule[field]
- @activateCtrlEnterListener element
for event in [ "input", "change" ]
- element.addEventListener event, enableSaveButton
+ element.addEventListener event, @onUpdated
- remove = row.querySelector ".exclusionRemoveButton"
- remove.addEventListener "click", (event) =>
- row = event.target.parentNode.parentNode
- row.parentNode.removeChild row
- enableSaveButton()
- @maintainExclusionMargin()
+ @getRemoveButton(row).addEventListener "click", (event) =>
+ rule = event.target.parentNode.parentNode
+ rule.parentNode.removeChild rule
+ @onUpdated()
@element.appendChild row
+ @element.children[@element.children.length-1]
readValueFromElement: ->
rules =
- for element in @element.children
- pattern = element.children[0].firstChild.value.trim()
- passKeys = element.children[1].firstChild.value.trim()
- { pattern: pattern, passKeys: passKeys }
+ for element in @element.getElementsByClassName "exclusionRuleTemplateInstance"
+ pattern: @getPattern(element).value.split(/\s+/).join ""
+ passKeys: @getPassKeys(element).value.split(/\s+/).join ""
rules.filter (rule) -> rule.pattern
areEqual: (a,b) ->
@@ -138,53 +128,123 @@ class ExclusionRulesOption extends Option
flatten = (rule) -> if rule and rule.pattern then rule.pattern + "\n" + rule.passKeys else ""
a.map(flatten).join("\n") == b.map(flatten).join("\n")
- # Hack. There has to be a better way than...
- # The y-axis scrollbar for "exclusionRules" is only displayed if it is needed. When visible, it appears on
- # top of the enclosed content (partially obscuring it). Here, we adjust the margin of the "Remove" button to
- # compensate.
- maintainExclusionMargin: ->
- scrollBox = $("exclusionScrollBox")
- margin = if scrollBox.clientHeight < scrollBox.scrollHeight then "16px" else "0px"
- for element in scrollBox.getElementsByClassName "exclusionRemoveButton"
- element.style["margin-right"] = margin
+ # Accessors for the three main sub-elements of an "exclusionRuleTemplateInstance".
+ getPattern: (element) -> element.querySelector(".pattern")
+ getPassKeys: (element) -> element.querySelector(".passKeys")
+ getRemoveButton: (element) -> element.querySelector(".exclusionRemoveButtonButton")
-#
-# Operations for page elements.
-enableSaveButton = ->
- $("saveOptions").removeAttribute "disabled"
-
-# Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints".
-maintainLinkHintsView = ->
- hide = (el) -> el.parentNode.parentNode.style.display = "none"
- show = (el) -> el.parentNode.parentNode.style.display = "table-row"
- if $("filterLinkHints").checked
- hide $("linkHintCharacters")
- show $("linkHintNumbers")
- else
- show $("linkHintCharacters")
- hide $("linkHintNumbers")
-
-toggleAdvancedOptions =
- do (advancedMode=false) ->
- (event) ->
- if advancedMode
- $("advancedOptions").style.display = "none"
- $("advancedOptionsLink").innerHTML = "Show advanced options&hellip;"
+# ExclusionRulesOnPopupOption is ExclusionRulesOption, extended with some UI tweeks suitable for use in the
+# page popup. This also differs from ExclusionRulesOption in that, on the page popup, there is always a URL
+# (@url) associated with the current tab.
+class ExclusionRulesOnPopupOption extends ExclusionRulesOption
+ constructor: (@url, args...) ->
+ super(args...)
+
+ addRule: ->
+ element = super @generateDefaultPattern()
+ @activatePatternWatcher element
+ # ExclusionRulesOption.addRule()/super() has focused the pattern. Here, focus the passKeys instead;
+ # because, in the popup, we already have a pattern, so the user is more likely to edit the passKeys.
+ @getPassKeys(element).focus()
+ # Return element (for consistency with ExclusionRulesOption.addRule()).
+ element
+
+ populateElement: (rules) ->
+ super(rules)
+ elements = @element.getElementsByClassName "exclusionRuleTemplateInstance"
+ @activatePatternWatcher element for element in elements
+
+ haveMatch = false
+ for element in elements
+ pattern = @getPattern(element).value.trim()
+ if 0 <= @url.search bgExclusions.RegexpCache.get pattern
+ haveMatch = true
+ @getPassKeys(element).focus()
+ else
+ element.style.display = 'none'
+ @addRule() unless haveMatch
+
+ # Provide visual feedback (make it red) when a pattern does not match the current tab's URL.
+ activatePatternWatcher: (element) ->
+ patternElement = element.children[0].firstChild
+ patternElement.addEventListener "keyup", =>
+ if @url.match bgExclusions.RegexpCache.get patternElement.value
+ patternElement.title = patternElement.style.color = ""
else
- $("advancedOptions").style.display = "table-row-group"
- $("advancedOptionsLink").innerHTML = "Hide advanced options"
- advancedMode = !advancedMode
- event.preventDefault()
+ patternElement.style.color = "red"
+ patternElement.title = "Red text means that the pattern does not\nmatch the current URL."
+
+ # Generate a default exclusion-rule pattern from a URL. This is then used to pre-populate the pattern on
+ # the page popup.
+ generateDefaultPattern: ->
+ if /^https?:\/\/./.test @url
+ # The common use case is to disable Vimium at the domain level.
+ # Generate "https?://www.example.com/*" from "http://www.example.com/path/to/page.html".
+ "https?:/" + @url.split("/",3)[1..].join("/") + "/*"
+ else if /^[a-z]{3,}:\/\/./.test @url
+ # Anything else which seems to be a URL.
+ @url.split("/",3).join("/") + "/*"
+ else
+ @url + "*"
+
+initOptionsPage = ->
+ onUpdated = ->
+ $("saveOptions").removeAttribute "disabled"
+ $("saveOptions").innerHTML = "Save Changes"
+
+ # Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints".
+ maintainLinkHintsView = ->
+ hide = (el) -> el.parentNode.parentNode.style.display = "none"
+ show = (el) -> el.parentNode.parentNode.style.display = "table-row"
+ if $("filterLinkHints").checked
+ hide $("linkHintCharacters")
+ show $("linkHintNumbers")
+ else
+ show $("linkHintCharacters")
+ hide $("linkHintNumbers")
+
+ toggleAdvancedOptions =
+ do (advancedMode=false) ->
+ (event) ->
+ if advancedMode
+ $("advancedOptions").style.display = "none"
+ $("advancedOptionsLink").innerHTML = "Show advanced options&hellip;"
+ else
+ $("advancedOptions").style.display = "table-row-group"
+ $("advancedOptionsLink").innerHTML = "Hide advanced options"
+ advancedMode = !advancedMode
+ event.preventDefault()
+ # Prevent the "advanced options" link from retaining the focus.
+ document.activeElement.blur()
+
+ activateHelpDialog = ->
+ showHelpDialog chrome.extension.getBackgroundPage().helpDialogHtml(true, true, "Command Listing"), frameId
+ # Prevent the "show help" link from retaining the focus when clicked.
+ document.activeElement.blur()
+
+ saveOptions = ->
+ Option.saveOptions()
+ $("saveOptions").disabled = true
+ $("saveOptions").innerHTML = "No Changes"
-activateHelpDialog = ->
- showHelpDialog chrome.extension.getBackgroundPage().helpDialogHtml(true, true, "Command Listing"), frameId
+ $("saveOptions").addEventListener "click", saveOptions
+ $("advancedOptionsLink").addEventListener "click", toggleAdvancedOptions
+ $("showCommands").addEventListener "click", activateHelpDialog
+ $("filterLinkHints").addEventListener "click", maintainLinkHintsView
-#
-# Initialization.
-document.addEventListener "DOMContentLoaded", ->
+ for element in document.getElementsByClassName "nonEmptyTextOption"
+ element.className = element.className + " example info"
+ element.innerHTML = "Leave empty to reset this option."
+
+ maintainLinkHintsView()
+ window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled
+
+ document.addEventListener "keyup", (event) ->
+ if event.ctrlKey and event.keyCode == 13
+ document.activeElement.blur() if document?.activeElement?.blur
+ saveOptions()
- # Populate options. The constructor adds each new object to "Option.all".
- new type(name,enableSaveButton) for name, type of {
+ options =
exclusionRules: ExclusionRulesOption
filterLinkHints: CheckBoxOption
hideHud: CheckBoxOption
@@ -196,20 +256,67 @@ document.addEventListener "DOMContentLoaded", ->
previousPatterns: NonEmptyTextOption
regexFindMode: CheckBoxOption
scrollStepSize: NumberOption
+ smoothScroll: CheckBoxOption
searchEngines: TextOption
searchUrl: NonEmptyTextOption
userDefinedLinkHintCss: TextOption
- }
- $("saveOptions").addEventListener "click", Option.saveOptions
- $("advancedOptionsLink").addEventListener "click", toggleAdvancedOptions
- $("showCommands").addEventListener "click", activateHelpDialog
- $("filterLinkHints").addEventListener "click", maintainLinkHintsView
+ # Populate options. The constructor adds each new object to "Option.all".
+ for name, type of options
+ new type(name,onUpdated)
+
+initPopupPage = ->
+ chrome.tabs.getSelected null, (tab) ->
+ exclusions = null
+ document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html")
+
+ updateState = ->
+ rule = bgExclusions.getRule tab.url, exclusions.readValueFromElement()
+ $("state").innerHTML = "Vimium will " +
+ if rule and rule.passKeys
+ "exclude <span class='code'>#{rule.passKeys}</span>"
+ else if rule
+ "be disabled"
+ else
+ "be enabled"
+
+ onUpdated = ->
+ $("helpText").innerHTML = "Type <strong>Ctrl-Enter</strong> to save and close."
+ $("saveOptions").removeAttribute "disabled"
+ $("saveOptions").innerHTML = "Save Changes"
+ updateState() if exclusions
+
+ saveOptions = ->
+ 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
+
+ document.addEventListener "keyup", (event) ->
+ if event.ctrlKey and event.keyCode == 13
+ saveOptions()
+ window.close()
- for element in document.getElementsByClassName "nonEmptyTextOption"
- element.className = element.className + " example info"
- element.innerHTML = "Leave empty to reset this option."
+ # Populate options. Just one, here.
+ exclusions = new ExclusionRulesOnPopupOption(tab.url, "exclusionRules", onUpdated)
- maintainLinkHintsView()
- window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled
+ updateState()
+ document.addEventListener "keyup", updateState
+
+#
+# Initialization.
+document.addEventListener "DOMContentLoaded", ->
+ xhr = new XMLHttpRequest()
+ xhr.open 'GET', chrome.extension.getURL('pages/exclusions.html'), true
+ xhr.onreadystatechange = ->
+ if xhr.readyState == 4
+ $("exclusionScrollBox").innerHTML = xhr.responseText
+ switch location.pathname
+ when "/pages/options.html" then initOptionsPage()
+ when "/pages/popup.html" then initPopupPage()
+
+ xhr.send()
diff --git a/pages/options.css b/pages/options.css
new file mode 100644
index 00000000..5b098c8f
--- /dev/null
+++ b/pages/options.css
@@ -0,0 +1,232 @@
+/* NOTE: This stylesheet is included in both options.html and popup.html. So changes here affect
+ both of these. */
+body {
+ font: 14px "DejaVu Sans", "Arial", sans-serif;
+ color: #303942;
+ margin: 0 auto;
+}
+a, a:visited { color: #15c; }
+a:active { color: #052577; }
+div#wrapper, #footerWrapper {
+ width: 540px;
+ margin-left: 35px;
+}
+header {
+ font-size: 18px;
+ font-weight: normal;
+ border-bottom: 1px solid #eee;
+ padding: 20px 0 15px 0;
+ width: 100%;
+}
+button {
+ -webkit-user-select: none;
+ -webkit-appearance: none;
+ background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);
+ border: 1px solid rgba(0, 0, 0, 0.25);
+ border-radius: 2px;
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);
+ color: #444;
+ font: inherit;
+ text-shadow: 0 1px 0 #f0f0f0;
+ height: 24px;
+ font-size: 12px;
+ padding: 0 10px;
+}
+button:hover {
+ background-image: -webkit-linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0);
+ border-color: rgba(0, 0, 0, 0.3);
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12), inset 0 1px 2px rgba(255, 255, 255, 0.95);
+ color: black;
+}
+button:active {
+ background-image: -webkit-linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7);
+ box-shadow: none;
+ text-shadow: none;
+}
+button[disabled], button[disabled]:hover, button[disabled]:active {
+ background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);
+ border: 1px solid rgba(0, 0, 0, 0.25);
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);
+ text-shadow: 0 1px 0 #f0f0f0;
+ color: #888;
+}
+input[type="checkbox"] {
+ -webkit-user-select: none;
+}
+label:hover {
+ color: black;
+}
+pre, code, .code {
+ font-family: Consolas, "Liberation Mono", Courier, monospace;
+}
+pre {
+ margin: 5px;
+ border-left: 1px solid #eee;
+ padding-left: 5px;
+
+}
+input, textarea {
+ box-sizing: border-box;
+}
+textarea {
+ /* Horizontal resizing is pretty screwy-looking. */
+ resize: vertical;
+}
+table#options{
+ width: 100%;
+ font-size: 14px;
+ position: relative;
+ border-spacing: 0 23px;
+}
+.example {
+ font-size: 12px;
+ line-height: 16px;
+ color: #979ca0;
+ margin-left: 20px;
+}
+.info {
+ margin-left: 0px;
+}
+.caption {
+ margin-right: 10px;
+ min-width: 130px;
+ padding-top: 3px;
+ vertical-align: top;
+}
+td { padding: 0; }
+div#exampleKeyMapping {
+ margin-left: 10px;
+ margin-top: 5px;
+}
+input#linkHintCharacters {
+ width: 100%;
+}
+input#linkHintNumbers {
+ width: 100%;
+}
+input#linkHintCharacters {
+ width: 100%;
+}
+input#scrollStepSize {
+ width: 40px;
+ margin-right: 3px;
+}
+textarea#userDefinedLinkHintCss, textarea#keyMappings, textarea#searchEngines {
+ width: 100%;;
+ min-height: 130px;
+ white-space: nowrap;
+}
+input#previousPatterns, input#nextPatterns {
+ width: 100%;
+}
+input#newTabUrl {
+ width: 100%;
+}
+input#searchUrl {
+ width: 100%;
+}
+#status {
+ margin-left: 10px;
+ font-size: 80%;
+}
+/* Make the caption in the settings table as small as possible, to pull the other fields to the right. */
+.caption {
+ width: 1px;
+ white-space: nowrap;
+}
+#buttonsPanel { width: 100%; }
+#advancedOptions { display: none; }
+#advancedOptionsLink { line-height: 24px; }
+.help {
+ position: absolute;
+ right: -320px;
+ width: 320px;
+}
+input:read-only {
+ background-color: #eee;
+ color: #666;
+ pointer-events: none;
+ -webkit-user-select: none;
+}
+input[type="text"], textarea {
+ border: 1px solid #bfbfbf;
+ border-radius: 2px;
+ color: #444;
+ font: inherit;
+ padding: 3px;
+}
+button:focus, input[type="text"]:focus, textarea:focus {
+ -webkit-transition: border-color 200ms;
+ border-color: #4d90fe;
+ outline: none;
+}
+/* Boolean options have a tighter form representation than text options. */
+td.booleanOption { font-size: 12px; }
+/* Ids and classes for rendering exclusionRules */
+#exclusionScrollBox {
+ overflow: scroll;
+ overflow-x: hidden;
+ overflow-y: auto;
+ /* Each exclusion rule is about 30px, so this allows 7 before scrolling */
+ max-height: 215px;
+ min-height: 75px;
+ border-radius: 2px;
+ color: #444;
+ width: 100%
+}
+#exclusionRules {
+ width: 100%;
+}
+.exclusionRulePassKeys {
+ width: 33%;
+}
+.exclusionRemoveButton {
+ width: 1px; /* 1px; smaller than the button itself. */
+}
+.exclusionRemoveButtonButton {
+ border: none;
+ background-color: #fff;
+ color: #979ca0;
+}
+.exclusionRemoveButtonButton:hover {
+ color: #444;
+}
+input.pattern, input.passKeys, .exclusionHeaderText {
+ width: 100%;
+ font-family: Consolas, "Liberation Mono", Courier, monospace;
+ font-size: 14px;
+}
+.exclusionHeaderText {
+ padding-left: 3px;
+ color: #979ca0;
+}
+#exclusionAddButton {
+ float: right;
+ margin-right: 0px;
+ margin-top: 5px;
+}
+#footer {
+ background: #f5f5f5;
+ border-top: 1px solid #979ca0;
+ position: fixed;
+ bottom: 0px;
+ z-index: 10;
+}
+#footer, #footerTable, #footerTableData {
+ width: 100%;
+}
+#endSpace {
+ /* Leave space for the fixed footer. */
+ min-height: 30px;
+ max-height: 30px;
+}
+#helpText {
+ font-size: 12px;
+}
+#saveOptionsTableData {
+ float: right;
+}
+#saveOptions {
+ white-space: nowrap;
+ width: 110px;
+}
diff --git a/pages/options.html b/pages/options.html
index 4f037ba5..d37646c4 100644
--- a/pages/options.html
+++ b/pages/options.html
@@ -1,218 +1,9 @@
<html>
<head>
<title>Vimium Options</title>
+ <link rel="stylesheet" type="text/css" href="options.css">
<script src="content_script_loader.js"></script>
- <style type="text/css" media="screen">
- body {
- font: 14px "DejaVu Sans", "Arial", sans-serif;
- color: #303942;
- width: 680px;
- margin: 0 auto;
- }
- a, a:visited { color: #15c; }
- a:active { color: #052577; }
- div#wrapper { width: 500px; }
- header {
- font-size: 18px;
- font-weight: normal;
- border-bottom: 1px solid #eee;
- padding: 20px 0 15px 0;
- width: 100%;
- }
- button {
- -webkit-user-select: none;
- -webkit-appearance: none;
- background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);
- border: 1px solid rgba(0, 0, 0, 0.25);
- border-radius: 2px;
- box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);
- color: #444;
- font: inherit;
- text-shadow: 0 1px 0 #f0f0f0;
- height: 24px;
- font-size: 12px;
- padding: 0 10px;
- }
- button:hover {
- background-image: -webkit-linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0);
- border-color: rgba(0, 0, 0, 0.3);
- box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12), inset 0 1px 2px rgba(255, 255, 255, 0.95);
- color: black;
- }
- button:active {
- background-image: -webkit-linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7);
- box-shadow: none;
- text-shadow: none;
- }
- button[disabled], button[disabled]:hover, button[disabled]:active {
- background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);
- border: 1px solid rgba(0, 0, 0, 0.25);
- box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);
- text-shadow: 0 1px 0 #f0f0f0;
- color: #888;
- }
- input[type="checkbox"] {
- -webkit-user-select: none;
- }
- label:hover {
- color: black;
- }
- pre, code, .code {
- font-family: Consolas, "Liberation Mono", Courier, monospace;
- }
- pre {
- margin: 5px;
- border-left: 1px solid #eee;
- padding-left: 5px;
-
- }
- input, textarea {
- box-sizing: border-box;
- }
- textarea {
- /* Horizontal resizing is pretty screwy-looking. */
- resize: vertical;
- }
- table#options{
- width: 100%;
- font-size: 14px;
- position: relative;
- border-spacing: 0 23px;
- }
- .example {
- font-size: 12px;
- line-height: 16px;
- color: #979ca0;
- margin-left: 20px;
- }
- .info {
- margin-left: 0px;
- }
- .caption {
- margin-right: 10px;
- min-width: 130px;
- padding-top: 3px;
- vertical-align: top;
- }
- td { padding: 0; }
- div#exampleKeyMapping {
- margin-left: 10px;
- margin-top: 5px;
- }
- input#linkHintCharacters {
- width: 100%;
- }
- input#linkHintNumbers {
- width: 100%;
- }
- input#linkHintCharacters {
- width: 100%;
- }
- input#scrollStepSize {
- width: 40px;
- margin-right: 3px;
- }
- textarea#userDefinedLinkHintCss {
- width: 100%;;
- min-height: 130px;
- white-space: nowrap;
- }
- textarea#keyMappings {
- width: 100%;
- min-height: 135px;
- white-space: nowrap;
- }
- textarea#searchEngines {
- width: 100%;
- min-height: 135px;
- white-space: nowrap;
- }
- input#previousPatterns, input#nextPatterns {
- width: 100%;
- }
- input#newTabUrl {
- width: 100%;
- }
- input#searchUrl {
- width: 100%;
- }
- #status {
- margin-left: 10px;
- font-size: 80%;
- }
- /* Make the caption in the settings table as small as possible, to pull the other fields to the right. */
- td:first-child {
- width: 1px;
- white-space: nowrap;
- }
- #buttonsPanel { width: 100%; }
- #advancedOptions { display: none; }
- #advancedOptionsLink { line-height: 24px; }
- #saveOptions { float: right; }
- #saveOptions { margin-right: 0; }
- #showHelpDialogMessage {
- width: 100%;
- color: #979ca0;
- font-size: 12px;
- }
- .help {
- position: absolute;
- right: -320px;
- width: 320px;
- }
- input:read-only {
- background-color: #eee;
- color: #666;
- pointer-events: none;
- -webkit-user-select: none;
- }
- input[type="text"], textarea {
- border: 1px solid #bfbfbf;
- border-radius: 2px;
- color: #444;
- font: inherit;
- padding: 3px;
- }
- button:focus, input[type="text"]:focus, textarea:focus {
- -webkit-transition: border-color 200ms;
- border-color: #4d90fe;
- outline: none;
- }
- /* Boolean options have a tighter form representation than text options. */
- td.booleanOption { font-size: 12px; }
- footer {
- padding: 15px 0;
- border-top: 1px solid #eee;
- }
- /* Ids and classes for rendering exclusionRules */
- #exclusionScrollBox {
- overflow: scroll;
- overflow-x: hidden;
- overflow-y: auto;
- height: 170px;
- border: 1px solid #bfbfbf;
- border-radius: 2px;
- color: #444;
- }
- input.pattern, input.passKeys {
- font-family: Consolas, "Liberation Mono", Courier, monospace;
- font-size: 14px;
- }
- .pattern {
- width: 250px;
- }
- .passKeys {
- width: 120px;
- }
- #exclusionAddButton {
- float: right;
- margin-top: 5px;
- margin-right: 0px;
- }
- </style>
-
- <script type="text/javascript" src="options.js"></script>
-
+ <script type="text/javascript" src="options.js"></script>
</head>
<body>
@@ -224,25 +15,16 @@
<td>
<div class="help">
<div class="example">
- The left column contains URL patterns. Vimium will be wholly or partially disabled for URLs
- matching these patterns. Patterns are Javascript regular expressions. Additionally, the
- character "*" matches any zero or more characters.
+ Wholly or partially disable Vimium. "Patterns" are URL regular expressions;
+ additionally, "*" matches any zero or more characters.
<br/><br/>
- The right column contains keys which Vimium would would normally handle, but which should
- instead be passed through to the underlying web page (for pages matching the
- pattern). If left empty, then Vimium will be wholly disabled.
+ If "Keys" is left empty, then vimium is wholly disabled.
+ Otherwise, just the listed keys are disabled (they are passed through).
</div>
</div>
<div>
<div id="exclusionScrollBox">
- <table id="exclusionRules"></table>
- <template id="exclusionRuleTemplate">
- <tr>
- <td><input/ type="text" class="pattern" placeholder="URL pattern"></td>
- <td><input/ type="text" class="passKeys" placeholder="Exclude keys"></td>
- <td><input/ type="button" class="exclusionRemoveButton" tabindex = "-1" value="&#x2716;"></td>
- </tr>
- </template>
+ <!-- Populated from exclusions.html by options.coffee. -->
</div>
<button id="exclusionAddButton">Add Rule</button>
</div>
@@ -272,24 +54,30 @@ unmapAll
<td verticalAlign="top">
<div class="help">
<div class="example">
- This adds search-engine shortcuts to the Vomnibar.<br/><br/>
- The format is:<br/>
- <pre>your-keyword: http://the-site.com/?q=%s</pre>
- %s will be replaced with your search terms.<br/>
- Lines which start with "#" are comments.
+ Add search-engine shortcuts to the Vomnibar. Format:<br/>
+ <pre>
+a: http://a.com/?q=%s
+b: http://b.com/?q=%s description
+" this is a comment
+# this is also a comment</pre>
+ %s is replaced with the search terms.
</div>
</div>
<textarea id="searchEngines"></textarea>
</td>
</tr>
<tr>
- <td><a href="#" id="advancedOptionsLink">Show advanced options&hellip;</a></td>
- <td><button id="saveOptions" disabled="true">Save Options</button></td>
+ <td colspan="2"><a href="#" id="advancedOptionsLink">Show advanced options&hellip;</a></td>
</tr>
<tbody id='advancedOptions'>
<tr>
<td class="caption">Scroll step size</td>
<td>
+ <div class="help">
+ <div class="example">
+ The size for basic movements (usually j/k/h/l).
+ </div>
+ </div>
<input id="scrollStepSize" type="number" />px
</td>
</tr>
@@ -306,7 +94,7 @@ unmapAll
</td>
</tr>
<tr>
- <td class="caption">Numbers used<br/> for filtered link hints</td>
+ <td class="caption">Numbers used<br/> for link hints</td>
<td verticalAlign="top">
<div class="help">
<div class="example">
@@ -318,6 +106,15 @@ unmapAll
</td>
</tr>
<tr>
+ <td class="caption" verticalAlign="top">Miscellaneous<br/>options</td>
+ <td verticalAlign="top" class="booleanOption">
+ <label>
+ <input id="smoothScroll" type="checkbox"/>
+ Use smooth scrolling
+ </label>
+ </td>
+ </tr>
+ <tr>
<td class="caption"></td>
<td verticalAlign="top" class="booleanOption">
<div class="help">
@@ -355,7 +152,7 @@ unmapAll
</div>
<label>
<input id="regexFindMode" type="checkbox"/>
- Treat find queries as regular expressions.
+ Treat find queries as regular expressions
</label>
</td>
</tr>
@@ -424,14 +221,28 @@ unmapAll
</tr>
</tbody>
</table>
+ </div>
- <br/>
+ <!-- Some extra space which is hidden underneath the footer. -->
+ <div id="endSpace"/>
- <footer id="showHelpDialogMessage">
- Type <strong>?</strong> to show the Vimium help dialog.
- <br/>
- Type <strong>Ctrl-Enter</strong> in text inputs to save all options.
- </footer>
+ <div id="footer">
+ <div id="footerWrapper">
+ <table id="footerTable">
+ <tr>
+ <td id="footerTableData">
+ <span id="helpText">
+ Type <strong>?</strong> to show the Vimium help dialog.
+ <br/>
+ Type <strong>Ctrl-Enter</strong> to save <i>all</i> options.
+ </span>
+ </td>
+ <td id="saveOptionsTableData">
+ <button id="saveOptions" disabled="true">No Changes</button>
+ </td>
+ </tr>
+ </table>
+ </div>
</div>
</body>
</html>
diff --git a/pages/popup.coffee b/pages/popup.coffee
deleted file mode 100644
index 99a4eb87..00000000
--- a/pages/popup.coffee
+++ /dev/null
@@ -1,110 +0,0 @@
-
-originalRule = undefined
-originalPattern = undefined
-originalPassKeys = undefined
-
-generateDefaultPattern = (url) ->
- if /^https?:\/\/./.test url
- # The common use case is to disable Vimium at the domain level.
- # Generate "https?://www.example.com/*" from "http://www.example.com/path/to/page.html".
- "https?:/" + url.split("/",3)[1..].join("/") + "/*"
- else if /^[a-z]{3,}:\/\/./.test url
- # Anything else which seems to be a URL.
- url.split("/",3).join("/") + "/*"
- else
- url + "*"
-
-reset = (initialize=false) ->
- document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html")
- chrome.tabs.getSelected null, (tab) ->
- isEnabled = chrome.extension.getBackgroundPage().isEnabledForUrl(url: tab.url)
- # Check if we have an existing exclusing rule for this page.
- if isEnabled.rule
- originalRule = isEnabled.rule
- originalPattern = originalRule.pattern
- originalPassKeys = originalRule.passKeys
- else
- originalRule = null
- originalPattern = generateDefaultPattern tab.url
- originalPassKeys = ""
- patternElement = document.getElementById("popupPattern")
- passKeysElement = document.getElementById("popupPassKeys")
- patternElement.value = originalPattern
- passKeysElement.value = originalPassKeys
- if initialize
- # Activate <Ctrl-Enter> to save.
- for element in [ patternElement, passKeysElement ]
- element.addEventListener "keyup", (event) ->
- if event.ctrlKey and event.keyCode == 13
- addExclusionRule()
- window.close()
- element.addEventListener "focus", -> document.getElementById("helpText").style.display = "block"
- element.addEventListener "blur", -> document.getElementById("helpText").style.display = "none"
- # Focus passkeys with cursor at the end (but only when creating popup).
- passKeysElement.focus()
- passKeysElement.setSelectionRange(passKeysElement.value.length, passKeysElement.value.length)
- onChange()
-
-onChange = ->
- # As the text in the popup's input elements is changed, update the the popup's buttons accordingly.
- # Aditionally, enable and disable those buttons as appropriate.
- pattern = document.getElementById("popupPattern").value.trim()
- passKeys = document.getElementById("popupPassKeys").value.trim()
- popupExclude = document.getElementById("popupExclude")
-
- document.getElementById("popupRemove").disabled =
- not (originalRule and pattern == originalPattern)
-
- if originalRule and pattern == originalPattern and passKeys == originalPassKeys
- popupExclude.disabled = true
- popupExclude.value = "Update Rule"
-
- else if originalRule and pattern == originalPattern
- popupExclude.disabled = false
- popupExclude.value = "Update Rule"
-
- else if originalRule
- popupExclude.disabled = false
- popupExclude.value = "Add Rule"
-
- else if pattern
- popupExclude.disabled = false
- popupExclude.value = "Add Rule"
-
- else
- popupExclude.disabled = true
- popupExclude.value = "Add Rule"
-
-showMessage = do ->
- timer = null
-
- hideConfirmationMessage = ->
- document.getElementById("confirmationMessage").setAttribute "style", "display: none"
- timer = null
-
- (message) ->
- document.getElementById("confirmationMessage").setAttribute "style", "display: inline-block"
- document.getElementById("confirmationMessage").innerHTML = message
- clearTimeout(timer) if timer
- timer = setTimeout(hideConfirmationMessage,2000)
-
-addExclusionRule = ->
- pattern = document.getElementById("popupPattern").value.trim()
- passKeys = document.getElementById("popupPassKeys").value.trim()
- chrome.extension.getBackgroundPage().addExclusionRule pattern, passKeys
- showMessage("Updated.")
- reset()
-
-removeExclusionRule = ->
- pattern = document.getElementById("popupPattern").value.trim()
- chrome.extension.getBackgroundPage().removeExclusionRule pattern
- showMessage("Removed.")
- reset()
-
-document.addEventListener "DOMContentLoaded", ->
- document.getElementById("popupExclude").addEventListener "click", addExclusionRule, false
- document.getElementById("popupRemove").addEventListener "click", removeExclusionRule, false
- for field in ["popupPattern", "popupPassKeys"]
- for event in ["input", "change"]
- document.getElementById(field).addEventListener event, onChange, false
- reset true
diff --git a/pages/popup.html b/pages/popup.html
index 691414f2..c7e2fd6f 100644
--- a/pages/popup.html
+++ b/pages/popup.html
@@ -1,85 +1,82 @@
<html>
<head>
+ <link rel="stylesheet" type="text/css" href="options.css">
<style>
* {
margin: 0px;
padding: 0px;
}
- #vimiumPopup { width: 400px; }
-
- #excludeControls {
- padding: 10px;
+ #helpText, #optionsLink, #state {
+ font-family : "Helvetica Neue", "Helvetica", "Arial", sans-serif;
+ font-size: 12px;
}
- #popupPattern, #popupPassKeys {
- margin: 5px;
- width: 330px;
- /* Match the corresponding font and font size used on the options page. */
- /* TODO (smblott): Match other styles from the options page. */
- font-family: Consolas, "Liberation Mono", Courier, monospace;
- font-size: 14px;
+ #helpText, #stateLine, #state { color: #979ca0; }
+ #exclusionAddButton { width: 80px; }
+
+ #saveOptions {
+ margin-top: 5px; /* Match #exclusionAddButton */
+ margin-left: 5px;
+ float: right;
}
- #confirmationMessage {
- display: inline-block;
- width: 18px;
- height: 13px;
- background: url(icons/check.png) 3px 2px no-repeat;
- display: none;
+ #state {
+ padding-left: 5px;
+ background: #f5f5f5;
+ width: 100%;
+ border-bottom: 1px solid #979ca0;
+ margin: 0px;
}
- #popupRemove { margin: 5px; }
- #popupExclude { margin: 5px; }
+ /* These are overridden from options.css. */
- #popupMenu ul {
- list-style: none;
- }
+ #endSpace, #footerWrapper { width: 500px; }
+ #footerWrapper { margin-left: 0px; }
- #popupMenu li, #popupMenu a:active, #popupMenu a:visited {
- color: #3F6EC2;
- display: block;
- border-top: 1px solid #DDDDDD;
- padding: 3px;
- padding-left: 10px;
+ /* Make exclusionScrollBox smaller than on the options page, because there are likely to be fewer
+ matching rules, and the popup obscures the underlying page.
+ */
+ #exclusionScrollBox {
+ max-height: 124px;
+ min-height: 124px;
}
- #popupMenu a:hover {
- background: #EEEEEE;
+ #endSpace { /* Leave space for the fixed footer. */
+ min-height: 40px;
+ max-height: 40px;
}
- #optionsLink {
- font-family : "Helvetica Neue", "Helvetica", "Arial", sans-serif;
- font-size: 12px;
- float: right;
- }
- #helpText {
- color: #979ca0;
- font-family : "Helvetica Neue", "Helvetica", "Arial", sans-serif;
- font-size: 12px;
- float: left;
- display: none;
- }
</style>
- <script src="popup.js"></script>
+ <script src="options.js"></script>
</head>
<body>
- <div id="vimiumPopup">
- <div id="excludeControls">
- <input id="popupPattern" placeholder="Pattern against which to match URLs..." type="text" /><br/>
- <input id="popupPassKeys" placeholder="Only exclude these keys..." type="text" /><br/>
- <input id="popupRemove" type="button" value="Remove Rule" />
- <input id="popupExclude" type="button" value="Add or Update Rule" />
- <span id="confirmationMessage">Text is added in popup.coffee.</span>
- </div>
+ <div id="state"></div>
+
+ <div id="exclusionScrollBox">
+ <!-- Populated from exclusions.html by options.coffee. -->
+ </div>
+
+ <!-- Some extra space which is hidden underneath the footer. -->
+ <div id="endSpace"/>
- <div id="popupMenu">
- <ul>
- <li>
- <span id="helpText">Type <strong>Ctrl-ENTER</strong> to save and close.</span>
- <a id="optionsLink" target="_blank">Options</a>
- </li>
- </ul>
+ <div id="footer">
+ <div id="footerWrapper">
+ <table>
+ <tr>
+ <td id="stateLine" style="width: 99%">
+ <span id="helpText">These are the rules matching this page.</span>
+ <br/>
+ <a id="optionsLink" target="_blank" tabindex="99999">Options</a>
+ </td>
+ <td valign="top">
+ <button id="exclusionAddButton">Add Rule</button>
+ </td>
+ <td valign="top">
+ <button id="saveOptions" disabled="true">No Changes</button>
+ </td>
+ </tr>
+ </table>
</div>
</div>
</body>
diff --git a/pages/ui_component_server.coffee b/pages/ui_component_server.coffee
new file mode 100644
index 00000000..8b43095b
--- /dev/null
+++ b/pages/ui_component_server.coffee
@@ -0,0 +1,27 @@
+
+# Fetch the Vimium secret, register the port recieved from the parent window, and stop listening for messages
+# on the window object. vimiumSecret is accessible only within the current instantion of Vimium. So a
+# malicious host page trying to register its own port can do no better than guessing.
+registerPort = (event) ->
+ chrome.storage.local.get "vimiumSecret", ({vimiumSecret: secret}) ->
+ return unless event.source == window.parent and event.data == secret
+ UIComponentServer.portOpen event.ports[0]
+ window.removeEventListener "message", registerPort
+
+window.addEventListener "message", registerPort
+
+UIComponentServer =
+ ownerPagePort: null
+ handleMessage: null
+
+ portOpen: (@ownerPagePort) ->
+ @ownerPagePort.onmessage = (event) =>
+ @handleMessage event if @handleMessage
+
+ registerHandler: (@handleMessage) ->
+
+ postMessage: (message) ->
+ @ownerPagePort.postMessage message if @ownerPagePort
+
+root = exports ? window
+root.UIComponentServer = UIComponentServer
diff --git a/pages/vomnibar.coffee b/pages/vomnibar.coffee
new file mode 100644
index 00000000..0ade7f0e
--- /dev/null
+++ b/pages/vomnibar.coffee
@@ -0,0 +1,235 @@
+#
+# This controls the contents of the Vomnibar iframe. We use an iframe to avoid changing the selection on the
+# page (useful for bookmarklets), ensure that the Vomnibar style is unaffected by the page, and simplify key
+# handling in vimium_frontend.coffee
+#
+Vomnibar =
+ vomnibarUI: null # the dialog instance for this window
+ getUI: -> @vomnibarUI
+ completers: {}
+
+ getCompleter: (name) ->
+ if (!(name of @completers))
+ @completers[name] = new BackgroundCompleter(name)
+ @completers[name]
+
+ #
+ # Activate the Vomnibox.
+ #
+ activate: (userOptions) ->
+ options =
+ completer: "omni"
+ query: ""
+ newTab: false
+ selectFirst: false
+ extend options, userOptions
+
+ options.refreshInterval = switch options.completer
+ when "omni" then 100
+ else 0
+
+ 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()
+
+class VomnibarUI
+ constructor: ->
+ @refreshInterval = 0
+ @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: ->
+ UIComponentServer.postMessage "hide"
+ @reset()
+
+ reset: ->
+ @completionList.style.display = ""
+ @input.value = ""
+ @updateTimer = null
+ @completions = []
+ @selection = @initialSelectionValue
+
+ updateSelection: ->
+ # We have taken the option to add some global state here (previousCompletionType) to tell if a search
+ # item has just appeared or disappeared, if that happens we either set the initialSelectionValue to 0 or 1
+ # I feel that this approach is cleaner than bubbling the state up from the suggestion level
+ # so we just inspect it afterwards
+ if @completions[0]
+ if @previousCompletionType != "search" && @completions[0].type == "search"
+ @selection = 0
+ else if @previousCompletionType == "search" && @completions[0].type != "search"
+ @selection = -1
+ for i in [0...@completionList.children.length]
+ @completionList.children[i].className = (if i == @selection then "vomnibarSelected" else "")
+ @previousCompletionType = @completions[0].type if @completions[0]
+
+ #
+ # 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.
+ #
+ actionFromKeyEvent: (event) ->
+ key = KeyboardUtils.getKeyChar(event)
+ if (KeyboardUtils.isEscape(event))
+ return "dismiss"
+ else if (key == "up" ||
+ (event.shiftKey && event.keyCode == keyCodes.tab) ||
+ (event.ctrlKey && (key == "k" || key == "p")))
+ return "up"
+ 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"
+
+ onKeydown: (event) =>
+ 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 == "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)
+ query = @input.value.trim()
+ # <Enter> on an empty vomnibar is a no-op.
+ return unless 0 < query.length
+ @hide()
+ chrome.runtime.sendMessage({
+ handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab"
+ url: query })
+ else
+ @update true, =>
+ # Shift+Enter will open the result in a new tab instead of the current tab.
+ @completions[@selection].performAction(openInNewTab)
+ @hide()
+
+ # 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)
+ @updateTimer = null
+ @refreshInterval)
+
+ @input.focus()
+
+ initDom: ->
+ @box = document.getElementById("vomnibar")
+
+ @input = @box.querySelector("input")
+ @input.addEventListener "input", @update
+ @input.addEventListener "keydown", @onKeydown
+ @completionList = @box.querySelector("ul")
+ @completionList.style.display = ""
+
+ window.addEventListener "focus", => @input.focus()
+
+#
+# Sends filter and refresh 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". */
+ 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.
+ #
+ 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(
+ handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab"
+ url: url,
+ selected: openInNewTab)
+
+ switchToTab: (tabId) -> chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: tabId })
+
+UIComponentServer.registerHandler (event) -> Vomnibar.activate event.data
+
+root = exports ? window
+root.Vomnibar = Vomnibar
diff --git a/pages/vomnibar.css b/pages/vomnibar.css
new file mode 100644
index 00000000..2042a6c4
--- /dev/null
+++ b/pages/vomnibar.css
@@ -0,0 +1,136 @@
+
+/* Vomnibar CSS */
+
+#vomnibar ol, #vomnibar ul {
+ list-style: none;
+ display: none;
+}
+
+#vomnibar {
+ display: block;
+ position: fixed;
+ width: calc(100% - 20px); /* adjusted to keep border radius and box-shadow visible*/
+ /*min-width: 400px;
+ top: 70px;
+ left: 50%;*/
+ top: 8px;
+ left: 8px;
+ /*margin: 0 0 0 -40%;*/
+ font-family: sans-serif;
+
+ background: #F1F1F1;
+ text-align: left;
+ border-radius: 4px;
+ box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8);
+ border: 1px solid #aaa;
+ /* One less than hint markers and the help dialog. */
+ z-index: 2147483646;
+}
+
+#vomnibar input {
+ color: #000;
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ font-size: 20px;
+ height: 34px;
+ margin-bottom: 0;
+ padding: 4px;
+ background-color: white;
+ border-radius: 3px;
+ border: 1px solid #E8E8E8;
+ box-shadow: #444 0px 0px 1px;
+ width: 100%;
+ outline: none;
+ box-sizing: border-box;
+}
+
+#vomnibar .vomnibarSearchArea {
+ display: block;
+ padding: 10px;
+ background-color: #F1F1F1;
+ border-radius: 4px 4px 0 0;
+ border-bottom: 1px solid #C6C9CE;
+}
+
+#vomnibar ul {
+ background-color: white;
+ border-radius: 0 0 4px 4px;
+ list-style: none;
+ padding: 10px 0;
+ padding-top: 0;
+}
+
+#vomnibar li {
+ border-bottom: 1px solid #ddd;
+ line-height: 1.1em;
+ padding: 7px 10px;
+ font-size: 16px;
+ color: black;
+ position: relative;
+ display: list-item;
+ margin: auto;
+}
+
+#vomnibar li:last-of-type {
+ border-bottom: none;
+}
+
+#vomnibar li .vomnibarTopHalf, #vomnibar li .vomnibarBottomHalf {
+ display: block;
+ overflow: hidden;
+}
+
+#vomnibar li .vomnibarBottomHalf {
+ font-size: 15px;
+ margin-top: 3px;
+ padding: 2px 0;
+}
+
+#vomnibar li .vomnibarIcon {
+ background-position-y: center;
+ background-size: 16px;
+ background-repeat: no-repeat;
+ padding-left: 20px;
+}
+
+#vomnibar li .vomnibarSource {
+ color: #777;
+ margin-right: 4px;
+}
+#vomnibar li .vomnibarRelevancy {
+ position: absolute;
+ right: 0;
+ top: 0;
+ padding: 5px;
+ background-color: white;
+ color: black;
+ font-family: monospace;
+ width: 100px;
+ overflow: hidden;
+}
+
+#vomnibar li .vomnibarUrl {
+ white-space: nowrap;
+ color: #224684;
+}
+
+#vomnibar li .vomnibarMatch {
+ font-weight: bold;
+ color: black;
+}
+
+#vomnibar li em, #vomnibar li .vomnibarTitle {
+ color: black;
+ margin-left: 4px;
+ font-weight: normal;
+}
+#vomnibar li em { font-style: italic; }
+#vomnibar li em .vomnibarMatch, #vomnibar li .vomnibarTitle .vomnibarMatch {
+ color: #333;
+ text-decoration: underline;
+}
+
+#vomnibar li.vomnibarSelected {
+ background-color: #BBCEE9;
+ font-weight: normal;
+}
+
diff --git a/pages/vomnibar.html b/pages/vomnibar.html
new file mode 100644
index 00000000..2ca463d0
--- /dev/null
+++ b/pages/vomnibar.html
@@ -0,0 +1,22 @@
+<html>
+ <head>
+ <title>Vomnibar</title>
+ <script type="text/javascript" src="../lib/utils.js"></script>
+ <script type="text/javascript" src="../lib/keyboard_utils.js"></script>
+ <script type="text/javascript" src="../lib/dom_utils.js"></script>
+ <script type="text/javascript" src="../lib/handler_stack.js"></script>
+ <script type="text/javascript" src="../lib/clipboard.js"></script>
+ <script type="text/javascript" src="ui_component_server.js"></script>
+ <script type="text/javascript" src="vomnibar.js"></script>
+ <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" />
+ <link rel="stylesheet" type="text/css" href="vomnibar.css" />
+ </head>
+ <body>
+ <div id="vomnibar" class="vimiumReset">
+ <div class="vimiumReset vomnibarSearchArea">
+ <input type="text" class="vimiumReset">
+ </div>
+ <ul class="vimiumReset"></ul>
+ </div>
+ </body>
+</html>
diff --git a/test_harnesses/vomnibar.html b/test_harnesses/vomnibar.html
index 4d50e749..820210b0 100644
--- a/test_harnesses/vomnibar.html
+++ b/test_harnesses/vomnibar.html
@@ -11,7 +11,7 @@
<script type="text/javascript" src="../lib/keyboard_utils.js"></script>
<script type="text/javascript" src="../lib/dom_utils.js"></script>
<script src="https://github.com/ooyala/livecss/raw/master/livecss.js"></script>
- <script type="text/javascript" src="../content_scripts/vomnibar.js"></script>
+ <script type="text/javascript" src="../pages/vomnibar.js"></script>
<link rel="stylesheet" type="text/css" href="../vimium.css" />
<script>
function setup() {
diff --git a/tests/dom_tests/chrome.coffee b/tests/dom_tests/chrome.coffee
index 7f99e27f..ad4ae74b 100644
--- a/tests/dom_tests/chrome.coffee
+++ b/tests/dom_tests/chrome.coffee
@@ -10,6 +10,9 @@ root.chrome = {
onMessage: {
addListener: ->
}
+ onDisconnect: {
+ addListener: ->
+ }
postMessage: ->
}
onMessage: {
@@ -17,5 +20,10 @@ root.chrome = {
}
sendMessage: ->
getManifest: ->
+ getURL: (url) -> "../../#{url}"
}
+ storage:
+ local:
+ get: ->
+ set: ->
}
diff --git a/tests/dom_tests/dom_tests.coffee b/tests/dom_tests/dom_tests.coffee
index 4a61877c..a4713a72 100644
--- a/tests/dom_tests/dom_tests.coffee
+++ b/tests/dom_tests/dom_tests.coffee
@@ -8,10 +8,23 @@ mockKeyboardEvent = (keyChar) ->
event.charCode = (if keyCodes[keyChar] isnt undefined then keyCodes[keyChar] else keyChar.charCodeAt(0))
event.keyIdentifier = "U+00" + event.charCode.toString(16)
event.keyCode = event.charCode
- event.stopImmediatePropagation = ->
- event.preventDefault = ->
+ event.stopImmediatePropagation = -> @suppressed = true
+ event.preventDefault = -> @suppressed = true
event
+# Some of these tests have side effects on the handler stack and active mode. Therefore, we take backups and
+# restore them on tear down.
+backupStackState = ->
+ Mode.backup = Mode.modes[..]
+ InsertMode.permanentInstance.exit()
+ handlerStack.backup = handlerStack.stack[..]
+restoreStackState = ->
+ for mode in Mode.modes
+ mode.exit() unless mode in Mode.backup
+ Mode.modes = Mode.backup
+ InsertMode.permanentInstance.exit()
+ handlerStack.stack = handlerStack.backup
+
#
# Retrieve the hint markers as an array object.
#
@@ -170,20 +183,36 @@ context "Input focus",
testContent = "<input type='text' id='first'/><input style='display:none;' id='second'/>
<input type='password' id='third' value='some value'/>"
document.getElementById("test-div").innerHTML = testContent
+ backupStackState()
tearDown ->
document.getElementById("test-div").innerHTML = ""
+ restoreStackState()
should "focus the right element", ->
focusInput 1
assert.equal "first", document.activeElement.id
- # deactivate the tabbing mode and its overlays
- handlerStack.bubbleEvent 'keydown', mockKeyboardEvent("A")
focusInput 100
assert.equal "third", document.activeElement.id
handlerStack.bubbleEvent 'keydown', mockKeyboardEvent("A")
+ # This is the same as above, but also verifies that focusInput activates insert mode.
+ should "activate insert mode", ->
+ focusInput 1
+ handlerStack.bubbleEvent 'focus', target: document.activeElement
+ assert.isTrue InsertMode.permanentInstance.isActive()
+
+ focusInput 100
+ handlerStack.bubbleEvent 'focus', target: document. activeElement
+ assert.isTrue InsertMode.permanentInstance.isActive()
+
+ should "select the previously-focused input when count is 1", ->
+ focusInput 100
+ handlerStack.bubbleEvent 'focus', target: document. activeElement
+ focusInput 1
+ assert.equal "third", document.activeElement.id
+
# TODO: these find prev/next link tests could be refactored into unit tests which invoke a function which has
# a tighter contract than goNext(), since they test minor aspects of goNext()'s link matching behavior, and we
# don't need to construct external state many times over just to test that.
@@ -243,9 +272,429 @@ context "Find prev / next links",
goNext()
assert.equal '#first', window.location.hash
-
createLinks = (n) ->
for i in [0...n] by 1
link = document.createElement("a")
link.textContent = "test"
document.getElementById("test-div").appendChild link
+
+# For these tests, we use "m" as a mapped key, "p" as a pass key, and "u" as an unmapped key.
+context "Normal mode",
+ setup ->
+ document.activeElement?.blur()
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "m"
+
+ tearDown ->
+ restoreStackState()
+
+ should "suppress mapped keys", ->
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "m"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+ should "not suppress unmapped keys", ->
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+context "Passkeys mode",
+ setup ->
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "mp"
+
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: ""
+
+ handlerStack.bubbleEvent "registerKeyQueue",
+ keyQueue: ""
+
+ tearDown ->
+ restoreStackState()
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: ""
+
+ handlerStack.bubbleEvent "registerKeyQueue",
+ keyQueue: ""
+
+ should "not suppress passKeys", ->
+ # First check normal-mode key (just to verify the framework).
+ for k in [ "m", "p" ]
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "p"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+ # Install passKey.
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: "p"
+
+ # Then verify passKey.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "p"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+ # And re-verify a mapped key.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "m"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+ should "suppress passKeys with a non-empty keyQueue", ->
+ # Install passKey.
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: "p"
+
+ # First check the key is indeed not suppressed.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "p"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+ handlerStack.bubbleEvent "registerKeyQueue",
+ keyQueue: "1"
+
+ # Now verify that the key is suppressed.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "p"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+context "Insert mode",
+ setup ->
+ document.activeElement?.blur()
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "m"
+
+ tearDown ->
+ backupStackState()
+
+ should "not suppress mapped keys in insert mode", ->
+ # First verify normal-mode key (just to verify the framework).
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "m"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+ # Install insert mode.
+ insertMode = new InsertMode
+ global: true
+
+ # Then verify insert mode.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "m"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+ insertMode.exit()
+
+ # Then verify that insert mode has been successfully removed.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "m"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+context "Triggering insert mode",
+ setup ->
+ document.activeElement?.blur()
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "m"
+
+ testContent = "<input type='text' id='first'/>
+ <input style='display:none;' id='second'/>
+ <input type='password' id='third' value='some value'/>"
+ document.getElementById("test-div").innerHTML = testContent
+
+ tearDown ->
+ restoreStackState()
+ document.getElementById("test-div").innerHTML = ""
+
+ should "trigger insert mode on focus of contentEditable elements", ->
+ handlerStack.bubbleEvent "focus",
+ target:
+ isContentEditable: true
+
+ assert.isTrue Mode.top().name == "insert" and Mode.top().isActive()
+
+ should "trigger insert mode on focus of text input", ->
+ document.getElementById("first").focus()
+ handlerStack.bubbleEvent "focus", { target: document.activeElement }
+
+ assert.isTrue Mode.top().name == "insert" and Mode.top().isActive()
+
+ should "trigger insert mode on focus of password input", ->
+ document.getElementById("third").focus()
+ handlerStack.bubbleEvent "focus", { target: document.activeElement }
+
+ assert.isTrue Mode.top().name == "insert" and Mode.top().isActive()
+
+ should "not handle suppressed events", ->
+ document.getElementById("first").focus()
+ handlerStack.bubbleEvent "focus", { target: document.activeElement }
+ assert.isTrue Mode.top().name == "insert" and Mode.top().isActive()
+
+ for event in [ "keydown", "keypress", "keyup" ]
+ # Because "m" is mapped, we expect insert mode to ignore it, and normal mode to suppress it.
+ key = mockKeyboardEvent "m"
+ InsertMode.suppressEvent key
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+
+context "Mode utilities",
+ setup ->
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "m"
+
+ testContent = "<input type='text' id='first'/>
+ <input style='display:none;' id='second'/>
+ <input type='password' id='third' value='some value'/>"
+ document.getElementById("test-div").innerHTML = testContent
+
+ tearDown ->
+ restoreStackState()
+ document.getElementById("test-div").innerHTML = ""
+
+ should "not have duplicate singletons", ->
+ count = 0
+
+ class Test extends Mode
+ constructor: ->
+ count += 1
+ super
+ singleton: Test
+
+ exit: ->
+ count -= 1
+ super()
+
+ assert.isTrue count == 0
+ for [1..10]
+ mode = new Test(); assert.isTrue count == 1
+
+ mode.exit()
+ assert.isTrue count == 0
+
+ should "exit on escape", ->
+ escape =
+ keyCode: 27
+
+ new Mode
+ exitOnEscape: true
+ name: "test"
+
+ assert.isTrue Mode.top().name == "test"
+ handlerStack.bubbleEvent "keydown", escape
+ assert.isTrue Mode.top().name != "test"
+
+ should "not exit on escape if not enabled", ->
+ escape =
+ keyCode: 27
+ keyIdentifier: ""
+ stopImmediatePropagation: ->
+
+ new Mode
+ exitOnEscape: false
+ name: "test"
+
+ assert.isTrue Mode.top().name == "test"
+ handlerStack.bubbleEvent "keydown", escape
+ assert.isTrue Mode.top().name == "test"
+
+ should "exit on blur", ->
+ element = document.getElementById("first")
+ element.focus()
+
+ new Mode
+ exitOnBlur: element
+ name: "test"
+
+ assert.isTrue Mode.top().name == "test"
+ handlerStack.bubbleEvent "blur", { target: element }
+ assert.isTrue Mode.top().name != "test"
+
+ should "not exit on blur if not enabled", ->
+ element = document.getElementById("first")
+ element.focus()
+
+ new Mode
+ exitOnBlur: null
+ name: "test"
+
+ assert.isTrue Mode.top().name == "test"
+ handlerStack.bubbleEvent "blur", { target: element }
+ assert.isTrue Mode.top().name == "test"
+
+ should "register state change", ->
+ enabled = null
+ passKeys = null
+
+ class Test extends Mode
+ constructor: ->
+ super
+ trackState: true
+
+ registerStateChange: ->
+ enabled = @enabled
+ passKeys = @passKeys
+
+ new Test()
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: "enabled"
+ passKeys: "passKeys"
+ assert.isTrue enabled == "enabled"
+ assert.isTrue passKeys == "passKeys"
+
+ should "suppress printable keys", ->
+ element = document.getElementById("first")
+ element.focus()
+ handlerStack.bubbleEvent "focus", { target: document.activeElement }
+
+ # Verify that a key is not suppressed.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+ new PostFindMode {}
+
+ # Verify that the key is now suppressed for keypress.
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent "keypress",
+ extend key,
+ srcElement: element
+ assert.isTrue key.suppressed
+
+ # Verify key is not suppressed with Control key.
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent "keypress",
+ extend key,
+ srcElement: element
+ ctrlKey: true
+ assert.isFalse key.suppressed
+
+ # Verify key is not suppressed with Meta key.
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent "keypress",
+ extend key,
+ srcElement: element
+ metaKey: true
+ assert.isFalse key.suppressed
+
+context "PostFindMode",
+ setup ->
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "m"
+
+ testContent = "<input type='text' id='first'/>
+ <input style='display:none;' id='second'/>
+ <input type='password' id='third' value='some value'/>"
+ document.getElementById("test-div").innerHTML = testContent
+
+ @escape =
+ keyCode: 27
+ keyIdentifier: ""
+ stopImmediatePropagation: ->
+ preventDefault: ->
+
+ @element = document.getElementById("first")
+ @element.focus()
+ handlerStack.bubbleEvent "focus", { target: document.activeElement }
+
+ tearDown ->
+ restoreStackState()
+ document.getElementById("test-div").innerHTML = ""
+
+ should "be a singleton", ->
+ count = 0
+
+ assert.isTrue Mode.top().name == "insert"
+ new PostFindMode @element
+ assert.isTrue Mode.top().name == "post-find"
+ new PostFindMode @element
+ assert.isTrue Mode.top().name == "post-find"
+ Mode.top().exit()
+ assert.isTrue Mode.top().name == "insert"
+
+ should "suppress unmapped printable keypress events", ->
+ # Verify key is passed through.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+ new PostFindMode @element
+
+ # Verify key is now suppressed for keypress.
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent "keypress",
+ extend key,
+ srcElement: @element
+ assert.isTrue key.suppressed
+
+ should "be clickable to focus", ->
+ new PostFindMode @element
+
+ assert.isTrue Mode.top().name != "insert"
+ handlerStack.bubbleEvent "click", { target: document.activeElement }
+ assert.isTrue Mode.top().name == "insert"
+
+ should "enter insert mode on immediate escape", ->
+
+ new PostFindMode @element
+ assert.isTrue Mode.top().name == "post-find"
+ handlerStack.bubbleEvent "keydown", @escape
+ assert.isTrue Mode.top().name == "insert"
+
+ should "not enter insert mode on subsequent escape", ->
+ new PostFindMode @element
+ assert.isTrue Mode.top().name == "post-find"
+ handlerStack.bubbleEvent "keydown", mockKeyboardEvent "u"
+ handlerStack.bubbleEvent "keydown", @escape
+ assert.isTrue Mode.top().name == "post-find"
+
+context "Mode badges",
+ setup ->
+ backupStackState()
+
+ tearDown ->
+ restoreStackState()
+
+ should "have no badge without passKeys", ->
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: ""
+
+ handlerStack.bubbleEvent "updateBadge", badge = { badge: "" }
+ assert.isTrue badge.badge == ""
+
+ should "have no badge with passKeys", ->
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: "p"
+
+ handlerStack.bubbleEvent "updateBadge", badge = { badge: "" }
+ assert.isTrue badge.badge == ""
+
+ should "have no badge when disabled", ->
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: false
+ passKeys: ""
+
+ new InsertMode()
+ handlerStack.bubbleEvent "updateBadge", badge = { badge: "" }
+ assert.isTrue badge.badge == ""
+
diff --git a/tests/dom_tests/dom_tests.html b/tests/dom_tests/dom_tests.html
index feddafac..33759abd 100644
--- a/tests/dom_tests/dom_tests.html
+++ b/tests/dom_tests/dom_tests.html
@@ -32,11 +32,18 @@
<script type="text/javascript" src="../../lib/utils.js"></script>
<script type="text/javascript" src="../../lib/keyboard_utils.js"></script>
<script type="text/javascript" src="../../lib/dom_utils.js"></script>
+ <script type="text/javascript" src="../../lib/rect.js"></script>
<script type="text/javascript" src="../../lib/handler_stack.js"></script>
<script type="text/javascript" src="../../lib/clipboard.js"></script>
+ <script type="text/javascript" src="../../content_scripts/ui_component.js"></script>
<script type="text/javascript" src="../../content_scripts/link_hints.js"></script>
<script type="text/javascript" src="../../content_scripts/vomnibar.js"></script>
<script type="text/javascript" src="../../content_scripts/scroller.js"></script>
+ <script type="text/javascript" src="../../content_scripts/mode.js"></script>
+ <script type="text/javascript" src="../../content_scripts/mode_passkeys.js"></script>
+ <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.js"></script>
<script type="text/javascript" src="../../content_scripts/vimium_frontend.js"></script>
<script type="text/javascript" src="../shoulda.js/shoulda.js"></script>
@@ -52,6 +59,5 @@
<h1>Vimium Tests</h1>
<div id="output-div"></div>
-
</body>
</html>
diff --git a/tests/dom_tests/dom_utils_test.coffee b/tests/dom_tests/dom_utils_test.coffee
index 130a3014..ad8bde3c 100644
--- a/tests/dom_tests/dom_utils_test.coffee
+++ b/tests/dom_tests/dom_utils_test.coffee
@@ -50,12 +50,6 @@ context "Check visibility",
assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'foo') != null
assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'bar') != null
- should "detect opacity:0 links as hidden", ->
- document.getElementById("test-div").innerHTML = """
- <a id='foo' style='opacity:0'>test</a>
- """
- assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo'
-
should "detect links that contain only floated / absolutely-positioned divs as visible", ->
document.getElementById("test-div").innerHTML = """
<a id='foo'>
diff --git a/tests/dom_tests/vomnibar_test.coffee b/tests/dom_tests/vomnibar_test.coffee
index b414fdfb..0e02bb7b 100644
--- a/tests/dom_tests/vomnibar_test.coffee
+++ b/tests/dom_tests/vomnibar_test.coffee
@@ -1,19 +1,34 @@
+vomnibarFrame = null
+
context "Keep selection within bounds",
setup ->
@completions = []
- oldGetCompleter = Vomnibar.getCompleter.bind Vomnibar
- stub Vomnibar, 'getCompleter', (name) =>
+
+ vomnibarFrame = Vomnibar.vomnibarUI.iframeElement.contentWindow
+
+ # The Vomnibar frame is dynamically injected, so inject our stubs here.
+ vomnibarFrame.Function::bind = Function::bind
+ vomnibarFrame.chrome = chrome
+
+ oldGetCompleter = vomnibarFrame.Vomnibar.getCompleter.bind vomnibarFrame.Vomnibar
+ stub vomnibarFrame.Vomnibar, 'getCompleter', (name) =>
completer = oldGetCompleter name
stub completer, 'filter', (query, callback) => callback(@completions)
completer
+ # Shoulda.js doesn't support async tests, so we have to hack around.
+ stub Vomnibar.vomnibarUI, "postMessage", (data) ->
+ vomnibarFrame.UIComponentServer.handleMessage {data}
+ stub vomnibarFrame.UIComponentServer, "postMessage", (data) ->
+ UIComponent.handleMessage {data}
+
tearDown ->
Vomnibar.vomnibarUI.hide()
should "set selection to position -1 for omni completion by default", ->
Vomnibar.activate()
- ui = Vomnibar.vomnibarUI
+ ui = vomnibarFrame.Vomnibar.vomnibarUI
@completions = []
ui.update(true)
@@ -29,7 +44,7 @@ context "Keep selection within bounds",
should "set selection to position 0 for bookmark completion if possible", ->
Vomnibar.activateBookmarks()
- ui = Vomnibar.vomnibarUI
+ ui = vomnibarFrame.Vomnibar.vomnibarUI
@completions = []
ui.update(true)
@@ -45,7 +60,7 @@ context "Keep selection within bounds",
should "keep selection within bounds", ->
Vomnibar.activate()
- ui = Vomnibar.vomnibarUI
+ ui = vomnibarFrame.Vomnibar.vomnibarUI
@completions = []
ui.update(true)
diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee
index e4966016..b7b73cc2 100644
--- a/tests/unit_tests/completion_test.coffee
+++ b/tests/unit_tests/completion_test.coffee
@@ -233,17 +233,24 @@ context "tab completer",
context "search engines",
setup ->
- searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s"
+ 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", ->
+ 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", ->
diff --git a/tests/unit_tests/exclusion_test.coffee b/tests/unit_tests/exclusion_test.coffee
index 25bd8125..b3ed7194 100644
--- a/tests/unit_tests/exclusion_test.coffee
+++ b/tests/unit_tests/exclusion_test.coffee
@@ -25,49 +25,33 @@ extend(global, require "../../background_scripts/main.js")
#
context "Excluded URLs and pass keys",
- # These tests have no setup, they use the default values from settings.coffee.
+ setup ->
+ Exclusions.postUpdateHook(
+ [
+ { pattern: "http*://mail.google.com/*", passKeys: "" }
+ { pattern: "http*://www.facebook.com/*", passKeys: "abab" }
+ { pattern: "http*://www.facebook.com/*", passKeys: "cdcd" }
+ { pattern: "http*://www.bbc.com/*", passKeys: "" }
+ { pattern: "http*://www.bbc.com/*", passKeys: "ab" }
+ ])
should "be disabled for excluded sites", ->
- rule = isEnabledForUrl({ url: 'http://www.google.com/calendar/page' })
- assert.isFalse rule.isEnableForUrl
+ rule = isEnabledForUrl({ url: 'http://mail.google.com/calendar/page' })
+ assert.isFalse rule.isEnabledForUrl
assert.isFalse rule.passKeys
- should "be enabled, but with pass keys", ->
- rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' })
- assert.isTrue rule.isEnabledForUrl
+ should "be disabled for excluded sites, one exclusion", ->
+ rule = isEnabledForUrl({ url: 'http://www.bbc.com/calendar/page' })
+ assert.isFalse rule.isEnabledForUrl
assert.isFalse rule.passKeys
- addExclusionRule("http*://www.facebook.com/*","oO")
+
+ should "be enabled, but with pass keys", ->
rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' })
assert.isTrue rule.isEnabledForUrl
- assert.equal rule.passKeys, 'oO'
+ assert.equal rule.passKeys, 'abcd'
should "be enabled", ->
rule = isEnabledForUrl({ url: 'http://www.twitter.com/pages' })
assert.isTrue rule.isEnabledForUrl
assert.isFalse rule.passKeys
- should "add a new excluded URL", ->
- rule = isEnabledForUrl({ url: 'http://www.example.com/page' })
- assert.isTrue rule.isEnabledForUrl
- addExclusionRule("http://www.example.com*")
- rule = isEnabledForUrl({ url: 'http://www.example.com/page' })
- assert.isFalse rule.isEnabledForUrl
- assert.isFalse rule.passKeys
-
- should "add a new excluded URL with passkeys", ->
- rule = isEnabledForUrl({ url: 'http://www.anotherexample.com/page' })
- assert.isTrue rule.isEnabledForUrl
- addExclusionRule("http://www.anotherexample.com/*","jk")
- rule = isEnabledForUrl({ url: 'http://www.anotherexample.com/page' })
- assert.isTrue rule.isEnabledForUrl
- assert.equal rule.passKeys, 'jk'
-
- should "update an existing excluded URL with passkeys", ->
- rule = isEnabledForUrl({ url: 'http://mail.google.com/page' })
- assert.isFalse rule.isEnabledForUrl
- assert.isFalse rule.passKeys
- addExclusionRule("http*://mail.google.com/*","jknp")
- rule = isEnabledForUrl({ url: 'http://mail.google.com/page' })
- assert.isTrue rule.isEnabledForUrl
- assert.equal rule.passKeys, 'jknp'
-
diff --git a/tests/unit_tests/handler_stack_test.coffee b/tests/unit_tests/handler_stack_test.coffee
index 0ed8f4c0..0ed85e63 100644
--- a/tests/unit_tests/handler_stack_test.coffee
+++ b/tests/unit_tests/handler_stack_test.coffee
@@ -23,6 +23,29 @@ context "handlerStack",
assert.isTrue @handler2Called
assert.isFalse @handler1Called
+ should "terminate bubbling on stopBubblingAndTrue, and be true", ->
+ @handlerStack.push { keydown: => @handler1Called = true }
+ @handlerStack.push { keydown: => @handler2Called = true; @handlerStack.stopBubblingAndTrue }
+ assert.isTrue @handlerStack.bubbleEvent 'keydown', {}
+ assert.isTrue @handler2Called
+ assert.isFalse @handler1Called
+
+ should "terminate bubbling on stopBubblingAndTrue, and be false", ->
+ @handlerStack.push { keydown: => @handler1Called = true }
+ @handlerStack.push { keydown: => @handler2Called = true; @handlerStack.stopBubblingAndFalse }
+ assert.isFalse @handlerStack.bubbleEvent 'keydown', {}
+ assert.isTrue @handler2Called
+ assert.isFalse @handler1Called
+
+ should "restart bubbling on restartBubbling", ->
+ @handler1Called = 0
+ @handler2Called = 0
+ id = @handlerStack.push { keydown: => @handler1Called++; @handlerStack.remove(id); @handlerStack.restartBubbling }
+ @handlerStack.push { keydown: => @handler2Called++; true }
+ assert.isTrue @handlerStack.bubbleEvent 'keydown', {}
+ assert.isTrue @handler1Called == 1
+ assert.isTrue @handler2Called == 2
+
should "remove handlers correctly", ->
@handlerStack.push { keydown: => @handler1Called = true }
handlerId = @handlerStack.push { keydown: => @handler2Called = true }
diff --git a/tests/unit_tests/rect_test.coffee b/tests/unit_tests/rect_test.coffee
new file mode 100644
index 00000000..cfb26b05
--- /dev/null
+++ b/tests/unit_tests/rect_test.coffee
@@ -0,0 +1,232 @@
+require "./test_helper.js"
+extend(global, require "../../lib/rect.js")
+
+context "Rect",
+ should "set rect properties correctly", ->
+ [x1, y1, x2, y2] = [1, 2, 3, 4]
+ rect = Rect.create x1, y1, x2, y2
+ assert.equal rect.left, x1
+ assert.equal rect.top, y1
+ assert.equal rect.right, x2
+ assert.equal rect.bottom, y2
+ assert.equal rect.width, x2 - x1
+ assert.equal rect.height, y2 - y1
+
+ should "translate rect horizontally", ->
+ [x1, y1, x2, y2] = [1, 2, 3, 4]
+ x = 5
+ rect1 = Rect.create x1, y1, x2, y2
+ rect2 = Rect.translate rect1, x
+
+ assert.equal rect1.left + x, rect2.left
+ assert.equal rect1.right + x, rect2.right
+
+ assert.equal rect1.width, rect2.width
+ assert.equal rect1.height, rect2.height
+ assert.equal rect1.top, rect2.top
+ assert.equal rect1.bottom, rect2.bottom
+
+ should "translate rect vertically", ->
+ [x1, y1, x2, y2] = [1, 2, 3, 4]
+ y = 5
+ rect1 = Rect.create x1, y1, x2, y2
+ rect2 = Rect.translate rect1, undefined, y
+
+ assert.equal rect1.top + y, rect2.top
+ assert.equal rect1.bottom + y, rect2.bottom
+
+ assert.equal rect1.width, rect2.width
+ assert.equal rect1.height, rect2.height
+ assert.equal rect1.left, rect2.left
+ assert.equal rect1.right, rect2.right
+
+context "Rect subtraction",
+ context "unchanged by rects outside",
+ should "left, above", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create -2, -2, -1, -1
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "left", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create -2, 0, -1, 1
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "left, below", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create -2, 2, -1, 3
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "right, above", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create 2, -2, 3, -1
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "right", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create 2, 0, 3, 1
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "right, below", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create 2, 2, 3, 3
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "above", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create 0, -2, 1, -1
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "below", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create 0, 2, 1, 3
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ context "unchanged by rects touching",
+ should "left, above", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create -1, -1, 0, 0
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "left", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create -1, 0, 0, 1
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "left, below", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create -1, 1, 0, 2
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "right, above", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create 1, -1, 2, 0
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "right", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create 1, 0, 2, 1
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "right, below", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create 1, 1, 2, 2
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "above", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create 0, -1, 1, 0
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "below", ->
+ rect1 = Rect.create 0, 0, 1, 1
+ rect2 = Rect.create 0, 1, 1, 2
+
+ rects = Rect.subtract rect1, rect2
+ assert.equal rects.length, 1
+ rect = rects[0]
+ assert.isTrue Rect.equals rect1, rect
+
+ should "have nothing when subtracting itself", ->
+ rect = Rect.create 0, 0, 1, 1
+ rects = Rect.subtract rect, rect
+ assert.equal rects.length, 0
+
+ should "not overlap subtracted rect", ->
+ rect = Rect.create 0, 0, 3, 3
+ for x in [-2..2]
+ for y in [-2..2]
+ for width in [1..3]
+ for height in [1..3]
+ subtractRect = Rect.create x, y, (x + width), (y + height)
+ resultRects = Rect.subtract rect, subtractRect
+ for resultRect in resultRects
+ assert.isFalse Rect.contains subtractRect, resultRect
+
+ should "be contained in original rect", ->
+ rect = Rect.create 0, 0, 3, 3
+ for x in [-2..2]
+ for y in [-2..2]
+ for width in [1..3]
+ for height in [1..3]
+ subtractRect = Rect.create x, y, (x + width), (y + height)
+ resultRects = Rect.subtract rect, subtractRect
+ for resultRect in resultRects
+ assert.isTrue Rect.contains rect, resultRect
+
+ should "contain the subtracted rect in the original minus the results", ->
+ rect = Rect.create 0, 0, 3, 3
+ for x in [-2..2]
+ for y in [-2..2]
+ for width in [1..3]
+ for height in [1..3]
+ subtractRect = Rect.create x, y, (x + width), (y + height)
+ resultRects = Rect.subtract rect, subtractRect
+ resultComplement = [Rect.copy rect]
+ for resultRect in resultRects
+ resultComplement = Array::concat.apply [],
+ (resultComplement.map (rect) -> Rect.subtract rect, resultRect)
+ assert.isTrue (resultComplement.length == 0 or resultComplement.length == 1)
+ if resultComplement.length == 1
+ complementRect = resultComplement[0]
+ assert.isTrue Rect.contains subtractRect, complementRect
diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee
index 4625457b..afe862a4 100644
--- a/tests/unit_tests/settings_test.coffee
+++ b/tests/unit_tests/settings_test.coffee
@@ -70,14 +70,15 @@ context "settings",
chrome.storage.sync.set { scrollStepSize: JSON.stringify(message) }
assert.equal message, Sync.message
- should "set search engines, retrieve them correctly and check that it has been parsed correctly", ->
- searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s"
- parsedSearchEngines = {"foo": "bar?q=%s", "baz": "qux?q=%s"}
+ 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
- assert.equal(searchEngines, Settings.get('searchEngines'))
result = Settings.getSearchEngines()
- assert.isTrue(parsedSearchEngines["foo"] == result["foo"] &&
- parsedSearchEngines["baz"] == result["baz"] && Object.keys(result).length == 2)
+ assert.equal Object.keys(result).length, 2
+ assert.equal "bar?q=%s", result["foo"].url
+ assert.isFalse result["foo"].description
+ assert.equal "qux?q=%s", result["baz"].url
+ assert.equal "baz description", result["baz"].description
should "sync a key which is not a known setting (without crashing)", ->
chrome.storage.sync.set { notASetting: JSON.stringify("notAUsefullValue") }
diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee
index 80750337..7f666068 100644
--- a/tests/unit_tests/test_chrome_stubs.coffee
+++ b/tests/unit_tests/test_chrome_stubs.coffee
@@ -41,7 +41,13 @@ exports.chrome =
addListener: () -> true
getAll: () -> true
+ browserAction:
+ setBadgeBackgroundColor: ->
storage:
+ # chrome.storage.local
+ local:
+ set: ->
+
# chrome.storage.onChanged
onChanged:
addListener: (func) -> @func = func
diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee
index 556f5b7a..88e9a15b 100644
--- a/tests/unit_tests/utils_test.coffee
+++ b/tests/unit_tests/utils_test.coffee
@@ -45,7 +45,8 @@ context "convertToUrl",
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%20ogle.com", Utils.convertToUrl("go ogle.com")
+ 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")
context "hasChromePrefix",
should "detect chrome prefixes of URLs", ->