aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CONTRIBUTING.md48
-rw-r--r--README.md12
-rw-r--r--background_scripts/completion.coffee40
-rw-r--r--background_scripts/main.coffee57
-rw-r--r--background_scripts/settings.coffee1
-rw-r--r--content_scripts/scroller.coffee251
-rw-r--r--content_scripts/vimium.css17
-rw-r--r--content_scripts/vimium_frontend.coffee41
-rw-r--r--lib/dom_utils.coffee4
-rw-r--r--pages/options.coffee1
-rw-r--r--pages/options.html13
-rw-r--r--pages/popup.coffee16
-rw-r--r--tests/unit_tests/completion_test.coffee34
13 files changed, 368 insertions, 167 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 b411bcba..dc5519d5 100644
--- a/background_scripts/completion.coffee
+++ b/background_scripts/completion.coffee
@@ -259,26 +259,44 @@ class DomainCompleter
# 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.
+# 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
- constructor: ->
- @timestamp = 1
- @cache = {}
+ timestamp: 1
+ current: -1
+ cache: {}
+ lastVisited: null
+ lastVisitedTime: null
+ timeDelta: 500 # Milliseconds.
- chrome.tabs.onActivated.addListener (activeInfo) => @add activeInfo.tabId
- chrome.tabs.onRemoved.addListener (tabId) => @remove tabId
+ constructor: ->
+ chrome.tabs.onActivated.addListener (activeInfo) => @register activeInfo.tabId
+ chrome.tabs.onRemoved.addListener (tabId) => @deregister tabId
chrome.tabs.onReplaced.addListener (addedTabId, removedTabId) =>
- @remove removedTabId
- @add addedTabId
+ @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
- add: (tabId) -> @cache[tabId] = ++@timestamp
- remove: (tabId) -> delete @cache[tabId]
+ 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 @cache[tabId] == @timestamp then 0.0 else @cache[tabId] / @timestamp
+ if tabId == @current then 0.0 else @cache[tabId] / @timestamp
tabRecency = new TabRecency()
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 b3a14c78..0964a289 100644
--- a/content_scripts/scroller.coffee
+++ b/content_scripts/scroller.coffee
@@ -1,14 +1,9 @@
-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'
@@ -21,74 +16,202 @@ scrollProperties =
viewSize: 'clientWidth'
}
-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]
- # 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] == 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 7998fe5c..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 {
@@ -401,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 62e655e7..21018049 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/pages/options.coffee b/pages/options.coffee
index 674f0b98..cd19fa37 100644
--- a/pages/options.coffee
+++ b/pages/options.coffee
@@ -177,6 +177,7 @@ document.addEventListener "DOMContentLoaded", ->
previousPatterns: NonEmptyTextOption
regexFindMode: CheckBoxOption
scrollStepSize: NumberOption
+ smoothScroll: CheckBoxOption
searchEngines: TextOption
searchUrl: NonEmptyTextOption
userDefinedLinkHintCss: TextOption
diff --git a/pages/options.html b/pages/options.html
index 68b615e3..b52974d6 100644
--- a/pages/options.html
+++ b/pages/options.html
@@ -361,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">
@@ -375,7 +384,7 @@ unmapAll
</td>
</tr>
<tr>
- <td class="caption">Miscellaneous<br/>options</td>
+ <td class="caption"></td>
<td verticalAlign="top" class="booleanOption">
<div class="help">
<div class="example">
@@ -398,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>
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/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee
index 88f59b7e..e4966016 100644
--- a/tests/unit_tests/completion_test.coffee
+++ b/tests/unit_tests/completion_test.coffee
@@ -399,21 +399,32 @@ 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()
- @tabRecency.add 3
- @tabRecency.add 2
- @tabRecency.add 9
- @tabRecency.add 1
- @tabRecency.remove 9
- @tabRecency.add 4
-
- should "have entries for active tabs", ->
+
+ 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]
- assert.isTrue @tabRecency.cache[4]
should "not have entries for removed tabs", ->
assert.isFalse @tabRecency.cache[9]
@@ -431,8 +442,9 @@ context "TabRecency",
should "rank tabs by recency", ->
assert.isTrue @tabRecency.recencyScore(3) < @tabRecency.recencyScore 2
assert.isTrue @tabRecency.recencyScore(2) < @tabRecency.recencyScore 1
- @tabRecency.add 3
- @tabRecency.add 4 # Making 3 the most recent tab which isn't the current tab.
+ @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