aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CONTRIBUTING.md48
-rw-r--r--README.md12
-rw-r--r--background_scripts/completion.coffee60
-rw-r--r--background_scripts/exclusions.coffee7
-rw-r--r--background_scripts/main.coffee57
-rw-r--r--background_scripts/settings.coffee1
-rw-r--r--content_scripts/scroller.coffee254
-rw-r--r--content_scripts/vimium.css24
-rw-r--r--content_scripts/vimium_frontend.coffee41
-rw-r--r--lib/dom_utils.coffee4
-rw-r--r--lib/utils.coffee19
-rw-r--r--pages/options.coffee39
-rw-r--r--pages/options.html178
-rw-r--r--pages/popup.coffee16
-rw-r--r--pages/popup.html2
-rw-r--r--tests/unit_tests/completion_test.coffee66
-rw-r--r--tests/unit_tests/test_chrome_stubs.coffee4
-rw-r--r--tests/unit_tests/utils_test.coffee7
18 files changed, 580 insertions, 259 deletions
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..20331785 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
diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee
index 23696185..dc5519d5 100644
--- a/background_scripts/completion.coffee
+++ b/background_scripts/completion.coffee
@@ -26,7 +26,6 @@ class Suggestion
generateHtml: ->
return @html if @html
- favIconUrl = @tabFavIconUrl or "#{@getUrlRoot(@url)}/favicon.ico"
relevancyHtml = if @showRelevancy then "<span class='relevancy'>#{@computeRelevancy()}</span>" else ""
# NOTE(philc): We're using these vimium-specific class names so we don't collide with the page's CSS.
@html =
@@ -35,8 +34,7 @@ class Suggestion
<span class="vimiumReset vomnibarSource">#{@type}</span>
<span class="vimiumReset vomnibarTitle">#{@highlightTerms(Utils.escapeHtml(@title))}</span>
</div>
- <div class="vimiumReset vomnibarBottomHalf vomnibarIcon"
- style="background-image: url(#{favIconUrl});">
+ <div class="vimiumReset vomnibarBottomHalf">
<span class="vimiumReset vomnibarUrl">#{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))}</span>
#{relevancyHtml}
</div>
@@ -238,7 +236,7 @@ class DomainCompleter
onComplete()
onPageVisited: (newPage) ->
- domain = @parseDomain(newPage.url)
+ domain = @parseDomainAndScheme newPage.url
if domain
slot = @domains[domain] ||= { entry: newPage, referenceCount: 0 }
# We want each entry in our domains hash to point to the most recent History entry for that domain.
@@ -250,15 +248,58 @@ class DomainCompleter
@domains = {}
else
toRemove.urls.forEach (url) =>
- domain = @parseDomain(url)
+ domain = @parseDomainAndScheme url
if domain and @domains[domain] and ( @domains[domain].referenceCount -= 1 ) == 0
delete @domains[domain]
- parseDomain: (url) -> url.split("/")[2] || ""
+ # Return something like "http://www.example.com" or false.
+ parseDomainAndScheme: (url) ->
+ Utils.hasFullUrlPrefix(url) and not Utils.hasChromePrefix(url) and url.split("/",3).join "/"
# Suggestions from the Domain completer have the maximum relevancy. They should be shown first in the list.
computeRelevancy: -> 1
+# TabRecency associates a logical timestamp with each tab id. These are used to provide an initial
+# recency-based ordering in the tabs vomnibar (which allows jumping quickly between recently-visited tabs).
+class TabRecency
+ timestamp: 1
+ current: -1
+ cache: {}
+ lastVisited: null
+ lastVisitedTime: null
+ timeDelta: 500 # Milliseconds.
+
+ constructor: ->
+ chrome.tabs.onActivated.addListener (activeInfo) => @register activeInfo.tabId
+ chrome.tabs.onRemoved.addListener (tabId) => @deregister tabId
+
+ chrome.tabs.onReplaced.addListener (addedTabId, removedTabId) =>
+ @deregister removedTabId
+ @register addedTabId
+
+ register: (tabId) ->
+ currentTime = new Date()
+ # Register tabId if it has been visited for at least @timeDelta ms. Tabs which are visited only for a
+ # very-short time (e.g. those passed through with `5J`) aren't registered as visited at all.
+ if @lastVisitedTime? and @timeDelta <= currentTime - @lastVisitedTime
+ @cache[@lastVisited] = ++@timestamp
+
+ @current = @lastVisited = tabId
+ @lastVisitedTime = currentTime
+
+ deregister: (tabId) ->
+ if tabId == @lastVisited
+ # Ensure we don't register this tab, since it's going away.
+ @lastVisited = @lastVisitedTime = null
+ delete @cache[tabId]
+
+ # Recently-visited tabs get a higher score (except the current tab, which gets a low score).
+ recencyScore: (tabId) ->
+ @cache[tabId] ||= 1
+ if tabId == @current then 0.0 else @cache[tabId] / @timestamp
+
+tabRecency = new TabRecency()
+
# Searches through all open tabs, matching on title and URL.
class TabCompleter
filter: (queryTerms, onComplete) ->
@@ -269,12 +310,14 @@ class TabCompleter
suggestions = results.map (tab) =>
suggestion = new Suggestion(queryTerms, "tab", tab.url, tab.title, @computeRelevancy)
suggestion.tabId = tab.id
- suggestion.tabFavIconUrl = tab.favIconUrl
suggestion
onComplete(suggestions)
computeRelevancy: (suggestion) ->
- RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title)
+ if suggestion.queryTerms.length
+ RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title)
+ else
+ tabRecency.recencyScore(suggestion.tabId)
# A completer which will return your search engines
class SearchEngineCompleter
@@ -547,3 +590,4 @@ root.SearchEngineCompleter = SearchEngineCompleter
root.HistoryCache = HistoryCache
root.RankingUtils = RankingUtils
root.RegexpCache = RegexpCache
+root.TabRecency = TabRecency
diff --git a/background_scripts/exclusions.coffee b/background_scripts/exclusions.coffee
index 3a8ef1e7..2b34238b 100644
--- a/background_scripts/exclusions.coffee
+++ b/background_scripts/exclusions.coffee
@@ -6,7 +6,12 @@ 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.
diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee
index b40907fb..3ec618c9 100644
--- a/background_scripts/main.coffee
+++ b/background_scripts/main.coffee
@@ -8,7 +8,7 @@ keyQueue = "" # Queue of keys typed
validFirstKeys = {}
singleKeyCommands = []
focusedFrame = null
-framesForTab = {}
+frameIdsForTab = {}
# Keys are either literal characters, or "named" - for example <a-b> (alt+b), <left> (left arrow) or <f12>
# This regular expression captures two groups: the first is a named key, the second is the remainder of
@@ -282,16 +282,14 @@ BackgroundCommands =
{ name: "toggleHelpDialog", dialogHtml: helpDialogHtml(), frameId:frameId }))
moveTabLeft: (count) -> moveTab(null, -count)
moveTabRight: (count) -> moveTab(null, count)
- nextFrame: (count) ->
+ nextFrame: (count,frameId) ->
chrome.tabs.getSelected(null, (tab) ->
- frames = framesForTab[tab.id].frames
- currIndex = getCurrFrameIndex(frames)
-
- # TODO: Skip the "top" frame (which doesn't actually have a <frame> tag),
- # since it exists only to contain the other frames.
- newIndex = (currIndex + count) % frames.length
-
- chrome.tabs.sendMessage(tab.id, { name: "focusFrame", frameId: frames[newIndex].id, highlight: true }))
+ frames = frameIdsForTab[tab.id]
+ # We can't always track which frame chrome has focussed, but here we learn that it's frameId; so add an
+ # additional offset such that we do indeed start from frameId.
+ count = (count + Math.max 0, frameIdsForTab[tab.id].indexOf frameId) % frames.length
+ frames = frameIdsForTab[tab.id] = [frames[count..]..., frames[0...count]...]
+ chrome.tabs.sendMessage(tab.id, { name: "focusFrame", frameId: frames[0], highlight: true }))
closeTabsOnLeft: -> removeTabsRelative "before"
closeTabsOnRight: -> removeTabsRelative "after"
@@ -347,7 +345,7 @@ updateOpenTabs = (tab) ->
scrollY: null
deletor: null
# Frames are recreated on refresh
- delete framesForTab[tab.id]
+ delete frameIdsForTab[tab.id]
setBrowserActionIcon = (tabId,path) ->
chrome.browserAction.setIcon({ tabId: tabId, path: path })
@@ -394,7 +392,7 @@ chrome.tabs.onUpdated.addListener (tabId, changeInfo, tab) ->
code: Settings.get("userDefinedLinkHintCss")
runAt: "document_start"
chrome.tabs.insertCSS tabId, cssConf, -> chrome.runtime.lastError
- updateOpenTabs(tab)
+ updateOpenTabs(tab) if changeInfo.url?
updateActiveState(tabId)
chrome.tabs.onAttached.addListener (tabId, attachedInfo) ->
@@ -429,7 +427,7 @@ chrome.tabs.onRemoved.addListener (tabId) ->
# scroll position)
tabInfoMap.deletor = -> delete tabInfoMap[tabId]
setTimeout tabInfoMap.deletor, 1000
- delete framesForTab[tabId]
+ delete frameIdsForTab[tabId]
chrome.tabs.onActiveChanged.addListener (tabId, selectInfo) -> updateActiveState(tabId)
@@ -554,9 +552,9 @@ checkKeyQueue = (keysToCheck, tabId, frameId) ->
refreshedCompletionKeys = true
else
if registryEntry.passCountToFunction
- BackgroundCommands[registryEntry.command](count)
+ BackgroundCommands[registryEntry.command](count, frameId)
else if registryEntry.noRepeat
- BackgroundCommands[registryEntry.command]()
+ BackgroundCommands[registryEntry.command](frameId)
else
repeatFunction(BackgroundCommands[registryEntry.command], count, 0, frameId)
@@ -603,21 +601,21 @@ openOptionsPageInNewTab = ->
chrome.tabs.create({ url: chrome.runtime.getURL("pages/options.html"), index: tab.index + 1 }))
registerFrame = (request, sender) ->
- unless framesForTab[sender.tab.id]
- framesForTab[sender.tab.id] = { frames: [] }
-
- if (request.is_top)
- focusedFrame = request.frameId
- framesForTab[sender.tab.id].total = request.total
+ (frameIdsForTab[sender.tab.id] ?= []).push request.frameId
- framesForTab[sender.tab.id].frames.push({ id: request.frameId })
-
-handleFrameFocused = (request, sender) -> focusedFrame = request.frameId
+unregisterFrame = (request, sender) ->
+ tabId = sender.tab.id
+ if frameIdsForTab[tabId]?
+ if request.tab_is_closing
+ updateOpenTabs sender.tab
+ else
+ frameIdsForTab[tabId] = frameIdsForTab[tabId].filter (id) -> id != request.frameId
-getCurrFrameIndex = (frames) ->
- for i in [0...frames.length]
- return i if frames[i].id == focusedFrame
- frames.length + 1
+handleFrameFocused = (request, sender) ->
+ tabId = sender.tab.id
+ if frameIdsForTab[tabId]?
+ frameIdsForTab[tabId] =
+ [request.frameId, (frameIdsForTab[tabId].filter (id) -> id != request.frameId)...]
# Port handler mapping
portHandlers =
@@ -633,6 +631,7 @@ sendRequestHandlers =
openUrlInCurrentTab: openUrlInCurrentTab,
openOptionsPageInNewTab: openOptionsPageInNewTab,
registerFrame: registerFrame,
+ unregisterFrame: unregisterFrame,
frameFocused: handleFrameFocused,
upgradeNotificationClosed: upgradeNotificationClosed,
updateScrollPosition: handleUpdateScrollPosition,
@@ -640,7 +639,7 @@ sendRequestHandlers =
isEnabledForUrl: isEnabledForUrl,
saveHelpDialogSettings: saveHelpDialogSettings,
selectSpecificTab: selectSpecificTab,
- refreshCompleter: refreshCompleter
+ refreshCompleter: refreshCompleter,
createMark: Marks.create.bind(Marks),
gotoMark: Marks.goto.bind(Marks)
diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee
index d6e8fcde..4a63a5fb 100644
--- a/background_scripts/settings.coffee
+++ b/background_scripts/settings.coffee
@@ -61,6 +61,7 @@ root.Settings = Settings =
# or strings
defaults:
scrollStepSize: 60
+ smoothScroll: true
keyMappings: "# Insert your prefered key mappings here."
linkHintCharacters: "sadfjklewcmpgh"
linkHintNumbers: "0123456789"
diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee
index f3c632b3..5eb1c5e2 100644
--- a/content_scripts/scroller.coffee
+++ b/content_scripts/scroller.coffee
@@ -1,93 +1,217 @@
-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
-
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 the requested amount, and false otherwise.
+performScroll = (element, direction, amount) ->
axisName = scrollProperties[direction].axisName
- element = activatedElement
- loop
- oldScrollValue = element[axisName]
- changeFn(element, axisName)
- break unless (element[axisName] == oldScrollValue && element != document.body)
- lastElement = element
- # 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] == amount + 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 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 = Math.sign 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
+
+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
+ keydown: (event) =>
+ @keyIsDown = true
+ @lastEvent = event
+ keyup: =>
+ @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 = Math.sign 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 DOMActivate: -> 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
+ 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) ->
+ return unless document.body or activatedElement
+ activatedElement ||= document.body
+
+ 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/vimium.css b/content_scripts/vimium.css
index 24f229f3..a8ae5583 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 use 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 */
@@ -122,7 +126,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; }
@@ -233,8 +238,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;
@@ -291,7 +296,7 @@ body.vimiumFindMode ::selection {
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;
+ z-index: 2147483645;
}
#vomnibar input {
@@ -352,13 +357,6 @@ body.vimiumFindMode ::selection {
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;
@@ -408,5 +406,5 @@ div#vimiumFlash {
padding: 1px;
background-color: transparent;
position: absolute;
- z-index: 99999;
+ z-index: 2147483648;
}
diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee
index 118f985e..469afe71 100644
--- a/content_scripts/vimium_frontend.coffee
+++ b/content_scripts/vimium_frontend.coffee
@@ -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: {}
@@ -101,7 +101,7 @@ initializePreDomReady = ->
settings.addEventListener("load", LinkHints.init.bind(LinkHints))
settings.load()
- Scroller.init()
+ Scroller.init settings
checkIfEnabledForUrl()
@@ -179,19 +179,24 @@ window.addEventListener "focus", ->
# Initialization tasks that must wait for the document to be ready.
#
initializeOnDomReady = ->
- registerFrame(window.top == window.self)
-
enterInsertModeIfElementIsFocused() if isEnabledForUrl
# Tell the background page we're in the dom ready state.
chrome.runtime.connect({ name: "domReady" })
-registerFrame = (is_top) ->
- chrome.runtime.sendMessage(
- handler: "registerFrame"
+registerFrame = ->
+ # Don't register frameset containers; focusing them is no use.
+ if document.body.tagName != "FRAMESET"
+ chrome.runtime.sendMessage
+ handler: "registerFrame"
+ frameId: frameId
+
+# Unregister the frame if we're going to exit.
+unregisterFrame = ->
+ chrome.runtime.sendMessage
+ handler: "unregisterFrame"
frameId: frameId
- is_top: is_top
- total: frames.length + 1)
+ tab_is_closing: window.top == window.self
#
# Enters insert mode if the currently focused element in the DOM is focusable.
@@ -367,7 +372,7 @@ onKeypress = (event) ->
else if (!isInsertMode() && !findMode)
if (isPassKey keyChar)
return undefined
- if (currentCompletionKeys.indexOf(keyChar) != -1)
+ if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar))
DomUtils.suppressEvent(event)
keyPort.postMessage({ keyChar:keyChar, frameId:frameId })
@@ -440,7 +445,7 @@ onKeydown = (event) ->
else if (!isInsertMode() && !findMode)
if (keyChar)
- if (currentCompletionKeys.indexOf(keyChar) != -1)
+ if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar))
DomUtils.suppressEvent event
handledKeydownEvents.push event
@@ -502,7 +507,7 @@ refreshCompletionKeys = (response) ->
chrome.runtime.sendMessage({ handler: "getCompletionKeys" }, refreshCompletionKeys)
isValidFirstKey = (keyChar) ->
- validFirstKeys[keyChar] || /[1-9]/.test(keyChar)
+ validFirstKeys[keyChar] || /^[1-9]/.test(keyChar)
onFocusCapturePhase = (event) ->
if (isFocusable(event.target) && !findMode)
@@ -529,6 +534,7 @@ isEmbed = (element) -> ["embed", "object"].indexOf(element.nodeName.toLowerCase(
# any element which makes it a rich text editor, like the notes on jjot.com.
#
isEditable = (target) ->
+ # Note: document.activeElement.isContentEditable is also rechecked in isInsertMode() dynamically.
return true if target.isContentEditable
nodeName = target.nodeName.toLowerCase()
# use a blacklist instead of a whitelist because new form controls are still being implemented for html5
@@ -552,6 +558,7 @@ window.enterInsertMode = (target) ->
# 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
# leave insert mode when the user presses <ESC>.
+# Note. This returns the truthiness of target, which is required by isInsertMode.
#
enterInsertModeWithoutShowingIndicator = (target) -> insertModeLock = target
@@ -560,7 +567,13 @@ exitInsertMode = (target) ->
insertModeLock = null
HUD.hide()
-isInsertMode = -> insertModeLock != null
+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
# should be called whenever rawQuery is modified.
updateFindModeQuery = ->
@@ -1055,6 +1068,8 @@ Tween =
state.onUpdate(value)
initializePreDomReady()
+window.addEventListener("DOMContentLoaded", registerFrame)
+window.addEventListener("unload", unregisterFrame)
window.addEventListener("DOMContentLoaded", initializeOnDomReady)
window.onbeforeunload = ->
diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee
index 73d29eb6..dfaa5d5f 100644
--- a/lib/dom_utils.coffee
+++ b/lib/dom_utils.coffee
@@ -32,8 +32,8 @@ DomUtils =
#
makeXPath: (elementArray) ->
xpath = []
- for i of elementArray
- xpath.push("//" + elementArray[i], "//xhtml:" + elementArray[i])
+ for element in elementArray
+ xpath.push("//" + element, "//xhtml:" + element)
xpath.join(" | ")
evaluateXPath: (xpath, resultType) ->
diff --git a/lib/utils.coffee b/lib/utils.coffee
index bbcee1a0..b7f8731a 100644
--- a/lib/utils.coffee
+++ b/lib/utils.coffee
@@ -26,28 +26,29 @@ Utils =
-> id += 1
hasChromePrefix: do ->
- chromePrefixes = [ "about:", "view-source:", "chrome-extension:", "data:" ]
+ chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:" ]
(url) ->
if 0 < url.indexOf ":"
for prefix in chromePrefixes
return true if url.startsWith prefix
false
+ hasFullUrlPrefix: do ->
+ urlPrefix = new RegExp "^[a-z]{3,}://."
+ (url) -> urlPrefix.test url
+
# Completes a partial URL (without scheme)
createFullUrl: (partialUrl) ->
- unless /^[a-z]{3,}:\/\//.test partialUrl
- "http://" + partialUrl
- else
- partialUrl
+ if @hasFullUrlPrefix(partialUrl) then partialUrl else ("http://" + partialUrl)
# Tries to detect if :str is a valid URL.
isUrl: (str) ->
- # Starts with a scheme: URL
- return true if /^[a-z]{3,}:\/\//.test str
-
# Must not contain spaces
return false if ' ' in str
+ # Starts with a scheme: URL
+ return true if @hasFullUrlPrefix str
+
# More or less RFC compliant URL host part parsing. This should be sufficient for our needs
urlRegex = new RegExp(
'^(?:([^:]+)(?::([^:]+))?@)?' + # user:password (optional) => \1, \2
@@ -98,7 +99,7 @@ Utils =
convertToUrl: (string) ->
string = string.trim()
- # Special-case about:[url] and view-source:[url]
+ # Special-case about:[url], view-source:[url] and the like
if Utils.hasChromePrefix string
string
else if Utils.isUrl string
diff --git a/pages/options.coffee b/pages/options.coffee
index f5968eb9..cd19fa37 100644
--- a/pages/options.coffee
+++ b/pages/options.coffee
@@ -42,13 +42,7 @@ class Option
@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()
+ $("saveOptions").innerHTML = "No Changes"
# Abstract method; only implemented in sub-classes.
# Populate the option's DOM element (@element) with the setting's current value.
@@ -66,7 +60,6 @@ class TextOption extends Option
constructor: (field,enableSaveButton) ->
super(field,enableSaveButton)
@element.addEventListener "input", enableSaveButton
- @activateCtrlEnterListener @element
populateElement: (value) -> @element.value = value
readValueFromElement: -> @element.value.trim()
@@ -74,7 +67,6 @@ class NonEmptyTextOption extends Option
constructor: (field,enableSaveButton) ->
super(field,enableSaveButton)
@element.addEventListener "input", enableSaveButton
- @activateCtrlEnterListener @element
populateElement: (value) -> @element.value = value
# If the new value is not empty, then return it. Otherwise, restore the default value.
@@ -89,7 +81,6 @@ class ExclusionRulesOption extends Option
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.
@@ -97,11 +88,8 @@ class ExclusionRulesOption extends Option
exclusionScrollBox.scrollTop = exclusionScrollBox.scrollHeight
populateElement: (rules) ->
- while @element.firstChild
- @element.removeChild @element.firstChild
for rule in rules
@appendRule rule
- @maintainExclusionMargin()
# Append a row for a new rule.
appendRule: (rule) ->
@@ -111,7 +99,6 @@ 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
@@ -120,13 +107,12 @@ class ExclusionRulesOption extends Option
row = event.target.parentNode.parentNode
row.parentNode.removeChild row
enableSaveButton()
- @maintainExclusionMargin()
@element.appendChild row
readValueFromElement: ->
rules =
- for element in @element.children
+ for element in @element.getElementsByClassName "exclusionRuleTemplateInstance"
pattern = element.children[0].firstChild.value.trim()
passKeys = element.children[1].firstChild.value.trim()
{ pattern: pattern, passKeys: passKeys }
@@ -138,20 +124,11 @@ 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
-
#
# Operations for page elements.
enableSaveButton = ->
$("saveOptions").removeAttribute "disabled"
+ $("saveOptions").innerHTML = "Save Changes"
# Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints".
maintainLinkHintsView = ->
@@ -175,9 +152,13 @@ toggleAdvancedOptions =
$("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.
+ document.activeElement.blur()
#
# Initialization.
@@ -196,6 +177,7 @@ document.addEventListener "DOMContentLoaded", ->
previousPatterns: NonEmptyTextOption
regexFindMode: CheckBoxOption
scrollStepSize: NumberOption
+ smoothScroll: CheckBoxOption
searchEngines: TextOption
searchUrl: NonEmptyTextOption
userDefinedLinkHintCss: TextOption
@@ -213,3 +195,8 @@ document.addEventListener "DOMContentLoaded", ->
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
+ Option.saveOptions()
+
diff --git a/pages/options.html b/pages/options.html
index 4f037ba5..b52974d6 100644
--- a/pages/options.html
+++ b/pages/options.html
@@ -6,12 +6,14 @@
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; }
+ div#wrapper, #footerWrapper {
+ width: 540px;
+ margin-left: 35px;
+ }
header {
font-size: 18px;
font-weight: normal;
@@ -112,21 +114,11 @@
width: 40px;
margin-right: 3px;
}
- textarea#userDefinedLinkHintCss {
+ textarea#userDefinedLinkHintCss, textarea#keyMappings, textarea#searchEngines {
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%;
}
@@ -141,20 +133,13 @@
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 {
+ .caption {
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;
@@ -180,34 +165,84 @@
}
/* 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;
+ max-height: 135px;
+ min-height: 75px;
border-radius: 2px;
color: #444;
+ width: 100%
+ }
+ #exclusionScrollBox::-webkit-scrollbar {
+ width: 12px;
}
- input.pattern, input.passKeys {
+ #exclusionScrollBox::-webkit-scrollbar-track {
+ -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3);
+ border-radius: 2px;
+ }
+
+ #exclusionScrollBox::-webkit-scrollbar-thumb {
+ border-radius: 2px;
+ -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.5);
+ }
+ #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;
}
- .pattern {
- width: 250px;
- }
- .passKeys {
- width: 120px;
+ .exclusionHeaderText {
+ padding-left: 3px;
+ color: #979ca0;
}
#exclusionAddButton {
float: right;
- margin-top: 5px;
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;
}
</style>
@@ -224,24 +259,28 @@
<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>
+ <table id="exclusionRules">
+ <tr>
+ <td><span class="exclusionHeaderText">Patterns</span></td>
+ <td><span class="exclusionHeaderText">Keys</span></td>
+ </tr>
+ </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>
+ <tr class="exclusionRuleTemplateInstance">
+ <td><input/ type="text" class="pattern" placeholder="URL pattern"></td>
+ <td class="exclusionRulePassKeys"><input/ type="text" class="passKeys" placeholder="Exclude keys"></td>
+ <td class="exclusionRemoveButton">
+ <input/ type="button" tabindex = "-1" class="exclusionRemoveButtonButton" value="&#x2716;"></td>
+ </tr>
</template>
</div>
<button id="exclusionAddButton">Add Rule</button>
@@ -283,13 +322,17 @@ unmapAll
</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 +349,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 +361,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 +407,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 +476,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
index 2ab97bef..99a4eb87 100644
--- a/pages/popup.coffee
+++ b/pages/popup.coffee
@@ -3,6 +3,17 @@ 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) ->
@@ -13,11 +24,8 @@ reset = (initialize=false) ->
originalPattern = originalRule.pattern
originalPassKeys = originalRule.passKeys
else
- # The common use case is to disable Vimium at the domain level.
- # This regexp will match "http://www.example.com/" from "http://www.example.com/path/to/page.html".
- domain = (tab.url.match(/[^\/]*\/\/[^\/]*\//) or tab.url) + "*"
originalRule = null
- originalPattern = domain
+ originalPattern = generateDefaultPattern tab.url
originalPassKeys = ""
patternElement = document.getElementById("popupPattern")
passKeysElement = document.getElementById("popupPassKeys")
diff --git a/pages/popup.html b/pages/popup.html
index 691414f2..775d6c07 100644
--- a/pages/popup.html
+++ b/pages/popup.html
@@ -76,7 +76,7 @@
<div id="popupMenu">
<ul>
<li>
- <span id="helpText">Type <strong>Ctrl-ENTER</strong> to save and close.</span>
+ <span id="helpText">Type <strong>Ctrl-Enter</strong> to save and close.</span>
<a id="optionsLink" target="_blank">Options</a>
</li>
</ul>
diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee
index 811436a9..e4966016 100644
--- a/tests/unit_tests/completion_test.coffee
+++ b/tests/unit_tests/completion_test.coffee
@@ -1,8 +1,8 @@
require "./test_helper.js"
extend(global, require "../../lib/utils.js")
extend(global, require "../../background_scripts/completion.js")
+extend global, require "./test_chrome_stubs.js"
-global.chrome = {}
global.document =
createElement: -> {}
@@ -163,13 +163,13 @@ context "domain completer",
should "return only a single matching domain", ->
results = filterCompleter(@completer, ["story"])
- assert.arrayEqual ["history1.com"], results.map (result) -> result.url
+ assert.arrayEqual ["http://history1.com"], results.map (result) -> result.url
should "pick domains which are more recent", ->
# These domains are the same except for their last visited time.
- assert.equal "history1.com", filterCompleter(@completer, ["story"])[0].url
+ assert.equal "http://history1.com", filterCompleter(@completer, ["story"])[0].url
@history2.lastVisitTime = hours(3)
- assert.equal "history2.com", filterCompleter(@completer, ["story"])[0].url
+ assert.equal "http://history2.com", filterCompleter(@completer, ["story"])[0].url
should "returns no results when there's more than one query term, because clearly it's not a domain", ->
assert.arrayEqual [], filterCompleter(@completer, ["his", "tory"])
@@ -194,15 +194,15 @@ context "domain completer (removing entries)",
should "remove 1 entry for domain with reference count of 1", ->
@onVisitRemovedListener { allHistory: false, urls: [@history1.url] }
- assert.equal "history2.com", filterCompleter(@completer, ["story"])[0].url
+ assert.equal "http://history2.com", filterCompleter(@completer, ["story"])[0].url
assert.equal 0, filterCompleter(@completer, ["story1"]).length
should "remove 2 entries for domain with reference count of 2", ->
@onVisitRemovedListener { allHistory: false, urls: [@history2.url] }
- assert.equal "history2.com", filterCompleter(@completer, ["story2"])[0].url
+ assert.equal "http://history2.com", filterCompleter(@completer, ["story2"])[0].url
@onVisitRemovedListener { allHistory: false, urls: [@history3.url] }
assert.equal 0, filterCompleter(@completer, ["story2"]).length
- assert.equal "history1.com", filterCompleter(@completer, ["story"])[0].url
+ assert.equal "http://history1.com", filterCompleter(@completer, ["story"])[0].url
should "remove 3 (all) matching domain entries", ->
@onVisitRemovedListener { allHistory: false, urls: [@history2.url] }
@@ -399,6 +399,58 @@ context "RegexpCache",
should "search for a string with a prefix/suffix (negative case)", ->
assert.isTrue "hound dog".search(RegexpCache.get("do", "\\b", "\\b")) == -1
+fakeTimeDeltaElapsing = ->
+
+context "TabRecency",
+ setup ->
+ @tabRecency = new TabRecency()
+
+ fakeTimeDeltaElapsing = =>
+ if @tabRecency.lastVisitedTime?
+ @tabRecency.lastVisitedTime = new Date(@tabRecency.lastVisitedTime - @tabRecency.timeDelta)
+
+ @tabRecency.register 3
+ fakeTimeDeltaElapsing()
+ @tabRecency.register 2
+ fakeTimeDeltaElapsing()
+ @tabRecency.register 9
+ fakeTimeDeltaElapsing()
+ @tabRecency.register 1
+ @tabRecency.deregister 9
+ fakeTimeDeltaElapsing()
+ @tabRecency.register 4
+ fakeTimeDeltaElapsing()
+
+ should "have entries for recently active tabs", ->
+ assert.isTrue @tabRecency.cache[1]
+ assert.isTrue @tabRecency.cache[2]
+ assert.isTrue @tabRecency.cache[3]
+
+ should "not have entries for removed tabs", ->
+ assert.isFalse @tabRecency.cache[9]
+
+ should "give a high score to the most recent tab", ->
+ assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 1
+ assert.isTrue @tabRecency.recencyScore(3) < @tabRecency.recencyScore 1
+ assert.isTrue @tabRecency.recencyScore(2) < @tabRecency.recencyScore 1
+
+ should "give a low score to the current tab", ->
+ assert.isTrue @tabRecency.recencyScore(1) > @tabRecency.recencyScore 4
+ assert.isTrue @tabRecency.recencyScore(2) > @tabRecency.recencyScore 4
+ assert.isTrue @tabRecency.recencyScore(3) > @tabRecency.recencyScore 4
+
+ should "rank tabs by recency", ->
+ assert.isTrue @tabRecency.recencyScore(3) < @tabRecency.recencyScore 2
+ assert.isTrue @tabRecency.recencyScore(2) < @tabRecency.recencyScore 1
+ @tabRecency.register 3
+ fakeTimeDeltaElapsing()
+ @tabRecency.register 4 # Making 3 the most recent tab which isn't the current tab.
+ assert.isTrue @tabRecency.recencyScore(1) < @tabRecency.recencyScore 3
+ assert.isTrue @tabRecency.recencyScore(2) < @tabRecency.recencyScore 3
+ assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 3
+ assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 1
+ assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 2
+
# A convenience wrapper around completer.filter() so it can be called synchronously in tests.
filterCompleter = (completer, queryTerms) ->
results = []
diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee
index 2abd26c9..80750337 100644
--- a/tests/unit_tests/test_chrome_stubs.coffee
+++ b/tests/unit_tests/test_chrome_stubs.coffee
@@ -30,6 +30,10 @@ exports.chrome =
addListener: () -> true
onActiveChanged:
addListener: () -> true
+ onActivated:
+ addListener: () -> true
+ onReplaced:
+ addListener: () -> true
query: () -> true
windows:
diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee
index b2d656ab..556f5b7a 100644
--- a/tests/unit_tests/utils_test.coffee
+++ b/tests/unit_tests/utils_test.coffee
@@ -61,6 +61,13 @@ context "hasChromePrefix",
assert.isFalse Utils.hasChromePrefix "data"
assert.isFalse Utils.hasChromePrefix "data :foobar"
+context "isUrl",
+ should "identify URLs as URLs", ->
+ assert.isTrue Utils.isUrl "http://www.example.com/blah"
+
+ should "identify non-URLs and non-URLs", ->
+ assert.isFalse Utils.isUrl "http://www.example.com/ blah"
+
context "Function currying",
should "Curry correctly", ->
foo = (a, b) -> "#{a},#{b}"