aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorStephen Blott2015-01-18 10:39:09 +0000
committerStephen Blott2015-01-18 10:39:09 +0000
commita1edae57e2847c2b6ffcae60ea8c9c16216e4692 (patch)
tree30ff186038028f9d0c0d5cc08d572ca56dda8819
parent8c9e429074580ea20aba662ee430d87bd73ebc4b (diff)
parent5d087c89917e21872711b7b908fcdd3c7e9e7f17 (diff)
downloadvimium-a1edae57e2847c2b6ffcae60ea8c9c16216e4692.tar.bz2
Merge pull request #1413 from smblott-github/modes
A modal-browsing framework
-rw-r--r--background_scripts/commands.coffee3
-rw-r--r--background_scripts/main.coffee21
-rw-r--r--content_scripts/link_hints.coffee26
-rw-r--r--content_scripts/mode.coffee202
-rw-r--r--content_scripts/mode_find.coffee66
-rw-r--r--content_scripts/mode_insert.coffee83
-rw-r--r--content_scripts/mode_passkeys.coffee24
-rw-r--r--content_scripts/mode_visual.coffee20
-rw-r--r--content_scripts/scroller.coffee15
-rw-r--r--content_scripts/vimium_frontend.coffee257
-rw-r--r--lib/dom_utils.coffee42
-rw-r--r--lib/handler_stack.coffee92
-rw-r--r--lib/keyboard_utils.coffee6
-rw-r--r--manifest.json5
-rw-r--r--tests/dom_tests/dom_tests.coffee451
-rw-r--r--tests/dom_tests/dom_tests.html5
-rw-r--r--tests/unit_tests/handler_stack_test.coffee23
-rw-r--r--tests/unit_tests/test_chrome_stubs.coffee2
18 files changed, 1204 insertions, 139 deletions
diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee
index 585ef572..485195a9 100644
--- a/background_scripts/commands.coffee
+++ b/background_scripts/commands.coffee
@@ -111,6 +111,7 @@ Commands =
"goUp",
"goToRoot",
"enterInsertMode",
+ "enterVisualMode",
"focusInput",
"LinkHints.activateMode",
"LinkHints.activateModeToOpenInNewTab",
@@ -195,6 +196,7 @@ defaultKeyMappings =
"gs": "toggleViewSource"
"i": "enterInsertMode"
+ "v": "enterVisualMode"
"H": "goBack"
"L": "goForward"
@@ -283,6 +285,7 @@ commandDescriptions =
openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true, repeatLimit: 20 }]
enterInsertMode: ["Enter insert mode", { noRepeat: true }]
+ enterVisualMode: ["Enter visual mode (not yet implemented)", { noRepeat: true }]
focusInput: ["Focus the first text box on the page. Cycle between them using tab",
{ passCountToFunction: true }]
diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee
index 4c1b9ae7..c1c8dfc8 100644
--- a/background_scripts/main.coffee
+++ b/background_scripts/main.coffee
@@ -339,6 +339,25 @@ updateOpenTabs = (tab) ->
setBrowserActionIcon = (tabId,path) ->
chrome.browserAction.setIcon({ tabId: tabId, path: path })
+chrome.browserAction.setBadgeBackgroundColor
+ # This is Vimium blue (from the icon).
+ # color: [102, 176, 226, 255]
+ # This is a slightly darker blue. It makes the badge more striking in the corner of the eye, and the symbol
+ # easier to read.
+ color: [82, 156, 206, 255]
+
+setBadge = do ->
+ current = null
+ timer = null
+ updateBadge = (badge) -> -> chrome.browserAction.setBadgeText text: badge
+ (request) ->
+ badge = request.badge
+ if badge? and badge != current
+ current = badge
+ clearTimeout timer if timer
+ # We wait a few moments. This avoids badge flicker when there are rapid changes.
+ timer = setTimeout updateBadge(badge), 50
+
# Updates the browserAction icon to indicate whether Vimium is enabled or disabled on the current page.
# Also propagates new enabled/disabled/passkeys state to active window, if necessary.
# This lets you disable Vimium on a page without needing to reload.
@@ -367,6 +386,7 @@ root.updateActiveState = updateActiveState = (tabId) ->
else
# We didn't get a response from the front end, so Vimium isn't running.
setBrowserActionIcon(tabId,disabledIcon)
+ setBadge {badge: ""}
handleUpdateScrollPosition = (request, sender) ->
updateScrollPosition(sender.tab, request.scrollX, request.scrollY)
@@ -633,6 +653,7 @@ sendRequestHandlers =
refreshCompleter: refreshCompleter
createMark: Marks.create.bind(Marks)
gotoMark: Marks.goto.bind(Marks)
+ setBadge: setBadge
# Convenience function for development use.
window.runTests = -> open(chrome.runtime.getURL('tests/dom_tests/dom_tests.html'))
diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee
index a4c084bc..2abfa001 100644
--- a/content_scripts/link_hints.coffee
+++ b/content_scripts/link_hints.coffee
@@ -8,13 +8,16 @@
# In 'filter' mode, our link hints are numbers, and the user can narrow down the range of possibilities by
# typing the text of the link itself.
#
-OPEN_IN_CURRENT_TAB = {}
-OPEN_IN_NEW_BG_TAB = {}
-OPEN_IN_NEW_FG_TAB = {}
-OPEN_WITH_QUEUE = {}
-COPY_LINK_URL = {}
-OPEN_INCOGNITO = {}
-DOWNLOAD_LINK_URL = {}
+# The "name" property below is a short-form name to appear in the link-hints mode name. Debugging only. The
+# key appears in the mode's badge.
+#
+OPEN_IN_CURRENT_TAB = { name: "curr-tab", key: "" }
+OPEN_IN_NEW_BG_TAB = { name: "bg-tab", key: "B" }
+OPEN_IN_NEW_FG_TAB = { name: "fg-tab", key: "F" }
+OPEN_WITH_QUEUE = { name: "queue", key: "Q" }
+COPY_LINK_URL = { name: "link", key: "C" }
+OPEN_INCOGNITO = { name: "incognito", key: "I" }
+DOWNLOAD_LINK_URL = { name: "download", key: "D" }
LinkHints =
hintMarkerContainingDiv: null
@@ -62,13 +65,13 @@ LinkHints =
@hintMarkerContainingDiv = DomUtils.addElementList(hintMarkers,
{ id: "vimiumHintMarkerContainer", className: "vimiumReset" })
- # handlerStack is declared by vimiumFrontend.js
- @handlerId = handlerStack.push({
+ @hintMode = new Mode
+ name: "hint/#{mode.name}"
+ badge: "#{mode.key}?"
keydown: @onKeyDownInMode.bind(this, hintMarkers),
# trap all key events
keypress: -> false
keyup: -> false
- })
setOpenLinkMode: (@mode) ->
if @mode is OPEN_IN_NEW_BG_TAB or @mode is OPEN_IN_NEW_FG_TAB or @mode is OPEN_WITH_QUEUE
@@ -276,6 +279,7 @@ LinkHints =
# TODO(philc): Ignore keys that have modifiers.
if (KeyboardUtils.isEscape(event))
+ DomUtils.suppressKeyupAfterEscape handlerStack
@deactivateMode()
else if (event.keyCode != keyCodes.shiftKey and event.keyCode != keyCodes.ctrlKey)
keyResult = @getMarkerMatcher().matchHintsByKey(hintMarkers, event)
@@ -339,7 +343,7 @@ LinkHints =
if (LinkHints.hintMarkerContainingDiv)
DomUtils.removeElement LinkHints.hintMarkerContainingDiv
LinkHints.hintMarkerContainingDiv = null
- handlerStack.remove @handlerId
+ @hintMode.exit()
HUD.hide()
@isActive = false
diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee
new file mode 100644
index 00000000..acc3978e
--- /dev/null
+++ b/content_scripts/mode.coffee
@@ -0,0 +1,202 @@
+#
+# A mode implements a number of keyboard (and possibly other) event handlers which are pushed onto the handler
+# stack when the mode is activated, and popped off when it is deactivated. The Mode class constructor takes a
+# single argument "options" which can define (amongst other things):
+#
+# name:
+# A name for this mode.
+#
+# badge:
+# A badge (to appear on the browser popup).
+# Optional. Define a badge if the badge is constant; for example, in find mode the badge is always "/".
+# Otherwise, do not define a badge, but instead override the updateBadge method; for example, in passkeys
+# mode, the badge may be "P" or "", depending on the configuration state. Or, if the mode *never* shows a
+# badge, then do neither.
+#
+# keydown:
+# keypress:
+# keyup:
+# Key handlers. Optional: provide these as required. The default is to continue bubbling all key events.
+#
+# Further options are described in the constructor, below.
+#
+# Additional handlers associated with a mode can be added by using the push method. For example, if a mode
+# responds to "focus" events, then push an additional handler:
+# @push
+# "focus": (event) => ....
+# Such handlers are removed when the mode is deactivated.
+#
+# The following events can be handled:
+# keydown, keypress, keyup, click, focus and blur
+
+# Debug only.
+count = 0
+
+class Mode
+ # If Mode.debug is true, then we generate a trace of modes being activated and deactivated on the console.
+ debug: false
+ @modes: []
+
+ # Constants; short, readable names for the return values expected by handlerStack.bubbleEvent.
+ continueBubbling: true
+ suppressEvent: false
+ stopBubblingAndTrue: handlerStack.stopBubblingAndTrue
+ stopBubblingAndFalse: handlerStack.stopBubblingAndFalse
+ restartBubbling: handlerStack.restartBubbling
+
+ constructor: (@options = {}) ->
+ @handlers = []
+ @exitHandlers = []
+ @modeIsActive = true
+ @badge = @options.badge || ""
+ @name = @options.name || "anonymous"
+
+ @count = ++count
+ @id = "#{@name}-#{@count}"
+ @log "activate:", @id
+
+ @push
+ keydown: @options.keydown || null
+ keypress: @options.keypress || null
+ keyup: @options.keyup || null
+ updateBadge: (badge) => @alwaysContinueBubbling => @updateBadge badge
+
+ # If @options.exitOnEscape is truthy, then the mode will exit when the escape key is pressed.
+ if @options.exitOnEscape
+ # Note. This handler ends up above the mode's own key handlers on the handler stack, so it takes
+ # priority.
+ @push
+ _name: "mode-#{@id}/exitOnEscape"
+ "keydown": (event) =>
+ return @continueBubbling unless KeyboardUtils.isEscape event
+ DomUtils.suppressKeyupAfterEscape handlerStack
+ @exit event, event.srcElement
+ @suppressEvent
+
+ # If @options.exitOnBlur is truthy, then it should be an element. The mode will exit when that element
+ # loses the focus.
+ if @options.exitOnBlur
+ @push
+ _name: "mode-#{@id}/exitOnBlur"
+ "blur": (event) => @alwaysContinueBubbling => @exit() if event.target == @options.exitOnBlur
+
+ # If @options.exitOnClick is truthy, then the mode will exit on any click event.
+ if @options.exitOnClick
+ @push
+ _name: "mode-#{@id}/exitOnClick"
+ "click": (event) => @alwaysContinueBubbling => @exit event
+
+ # Some modes are singletons: there may be at most one instance active at any time. A mode is a singleton
+ # if @options.singleton is truthy. The value of @options.singleton should be the key which is intended to
+ # be unique. New instances deactivate existing instances with the same key.
+ if @options.singleton
+ do =>
+ singletons = Mode.singletons ||= {}
+ key = @options.singleton
+ @onExit => delete singletons[key] if singletons[key] == @
+ if singletons[key]
+ @log "singleton:", "deactivating #{singletons[key].id}"
+ singletons[key].exit()
+ singletons[key] = @
+
+ # If @options.trackState is truthy, then the mode mainatins the current state in @enabled and @passKeys,
+ # and calls @registerStateChange() (if defined) whenever the state changes. The mode also tracks the
+ # current keyQueue in @keyQueue.
+ if @options.trackState
+ @enabled = false
+ @passKeys = ""
+ @keyQueue = ""
+ @push
+ _name: "mode-#{@id}/registerStateChange"
+ registerStateChange: ({ enabled: enabled, passKeys: passKeys }) => @alwaysContinueBubbling =>
+ if enabled != @enabled or passKeys != @passKeys
+ @enabled = enabled
+ @passKeys = passKeys
+ @registerStateChange?()
+ registerKeyQueue: ({ keyQueue: keyQueue }) => @alwaysContinueBubbling => @keyQueue = keyQueue
+
+ Mode.modes.push @
+ Mode.updateBadge()
+ @logModes()
+ # End of Mode constructor.
+
+ push: (handlers) ->
+ handlers._name ||= "mode-#{@id}"
+ @handlers.push handlerStack.push handlers
+
+ unshift: (handlers) ->
+ handlers._name ||= "mode-#{@id}"
+ @handlers.push handlerStack.unshift handlers
+
+ onExit: (handler) ->
+ @exitHandlers.push handler
+
+ exit: ->
+ if @modeIsActive
+ @log "deactivate:", @id
+ handler() for handler in @exitHandlers
+ handlerStack.remove handlerId for handlerId in @handlers
+ Mode.modes = Mode.modes.filter (mode) => mode != @
+ Mode.updateBadge()
+ @modeIsActive = false
+
+ # The badge is chosen by bubbling an "updateBadge" event down the handler stack allowing each mode the
+ # opportunity to choose a badge. This is overridden in sub-classes.
+ updateBadge: (badge) ->
+ badge.badge ||= @badge
+
+ # Shorthand for an otherwise long name. This wraps a handler with an arbitrary return value, and always
+ # yields @continueBubbling instead. This simplifies handlers if they always continue bubbling (a common
+ # case), because they do not need to be concerned with the value they yield.
+ alwaysContinueBubbling: handlerStack.alwaysContinueBubbling
+
+ # Static method. Used externally and internally to initiate bubbling of an updateBadge event and to send
+ # the resulting badge to the background page. We only update the badge if this document (hence this frame)
+ # has the focus.
+ @updateBadge: ->
+ if document.hasFocus()
+ handlerStack.bubbleEvent "updateBadge", badge = badge: ""
+ chrome.runtime.sendMessage
+ handler: "setBadge"
+ badge: badge.badge
+
+ # Debugging routines.
+ logModes: ->
+ if @debug
+ @log "active modes (top to bottom):"
+ @log " ", mode.id for mode in Mode.modes[..].reverse()
+
+ log: (args...) ->
+ console.log args... if @debug
+
+ # Return the must-recently activated mode (only used in tests).
+ @top: ->
+ @modes[@modes.length-1]
+
+# BadgeMode is a pseudo mode for triggering badge updates on focus changes and state updates. It sits at the
+# bottom of the handler stack, and so it receives state changes *after* all other modes, and can override the
+# badge choice of the other modes. We create the the one-and-only instance here.
+new class BadgeMode extends Mode
+ constructor: () ->
+ super
+ name: "badge"
+ trackState: true
+
+ # FIXME(smblott) BadgeMode is currently triggering an updateBadge event on every focus event. That's a
+ # lot, considerably more than necessary. Really, it only needs to trigger when we change frame, or when
+ # we change tab.
+ @push
+ _name: "mode-#{@id}/focus"
+ "focus": => @alwaysContinueBubbling -> Mode.updateBadge()
+
+ updateBadge: (badge) ->
+ # If we're not enabled, then post an empty badge.
+ badge.badge = "" unless @enabled
+
+ # When the registerStateChange event bubbles to the bottom of the stack, all modes have been notified. So
+ # it's now time to update the badge.
+ registerStateChange: ->
+ Mode.updateBadge()
+
+root = exports ? window
+root.Mode = Mode
diff --git a/content_scripts/mode_find.coffee b/content_scripts/mode_find.coffee
new file mode 100644
index 00000000..dff63949
--- /dev/null
+++ b/content_scripts/mode_find.coffee
@@ -0,0 +1,66 @@
+# NOTE(smblott). Ultimately, all of the FindMode-related code should be moved here.
+
+# This prevents unmapped printable characters from being passed through to underlying page; see #1415. Only
+# used by PostFindMode, below.
+class SuppressPrintable extends Mode
+ constructor: (options) ->
+ super options
+ handler = (event) => if KeyboardUtils.isPrintable event then @suppressEvent else @continueBubbling
+ type = document.getSelection().type
+
+ # We use unshift here, so we see events after normal mode, so we only see unmapped keys.
+ @unshift
+ _name: "mode-#{@id}/suppress-printable"
+ keydown: handler
+ keypress: handler
+ keyup: (event) =>
+ # If the selection type has changed (usually, no longer "Range"), then the user is interacting with
+ # the input element, so we get out of the way. See discussion of option 5c from #1415.
+ if document.getSelection().type != type then @exit() else handler event
+
+# When we use find, the selection/focus can land in a focusable/editable element. In this situation, special
+# considerations apply. We implement three special cases:
+# 1. Disable insert mode, because the user hasn't asked to enter insert mode. We do this by using
+# InsertMode.suppressEvent.
+# 2. Prevent unmapped printable keyboard events from propagating to the page; see #1415. We do this by
+# inheriting from SuppressPrintable.
+# 3. If the very-next keystroke is Escape, then drop immediately into insert mode.
+#
+class PostFindMode extends SuppressPrintable
+ constructor: ->
+ return unless document.activeElement and DomUtils.isEditable document.activeElement
+ element = document.activeElement
+
+ super
+ name: "post-find"
+ # We show a "?" badge, but only while an Escape activates insert mode.
+ badge: "?"
+ singleton: PostFindMode
+ exitOnBlur: element
+ exitOnClick: true
+ keydown: (event) -> InsertMode.suppressEvent event # Always truthy, so always continues bubbling.
+ keypress: (event) -> InsertMode.suppressEvent event
+ keyup: (event) -> InsertMode.suppressEvent event
+
+ # If the very-next keydown is Escape, then exit immediately, thereby passing subsequent keys to the
+ # underlying insert-mode instance.
+ @push
+ _name: "mode-#{@id}/handle-escape"
+ keydown: (event) =>
+ if KeyboardUtils.isEscape event
+ DomUtils.suppressKeyupAfterEscape handlerStack
+ @exit()
+ @suppressEvent
+ else
+ handlerStack.remove()
+ @badge = ""
+ Mode.updateBadge()
+ @continueBubbling
+
+ updateBadge: (badge) ->
+ badge.badge ||= @badge
+ # Suppress the "I" badge from insert mode.
+ InsertMode.suppressEvent badge # Always truthy.
+
+root = exports ? window
+root.PostFindMode = PostFindMode
diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee
new file mode 100644
index 00000000..196f910b
--- /dev/null
+++ b/content_scripts/mode_insert.coffee
@@ -0,0 +1,83 @@
+
+class InsertMode extends Mode
+ # There is one permanently-installed instance of InsertMode. It tracks focus changes and
+ # activates/deactivates itself (by setting @insertModeLock) accordingly.
+ @permanentInstance: null
+
+ constructor: (options = {}) ->
+ InsertMode.permanentInstance ||= @
+ @permanent = (@ == InsertMode.permanentInstance)
+
+ # If truthy, then we were activated by the user (with "i").
+ @global = options.global
+
+ handleKeyEvent = (event) =>
+ return @continueBubbling unless @isActive event
+ return @stopBubblingAndTrue unless event.type == 'keydown' and KeyboardUtils.isEscape event
+ DomUtils.suppressKeyupAfterEscape handlerStack
+ @exit event, event.srcElement
+ @suppressEvent
+
+ defaults =
+ name: "insert"
+ keypress: handleKeyEvent
+ keyup: handleKeyEvent
+ keydown: handleKeyEvent
+
+ super extend defaults, options
+
+ @insertModeLock =
+ if document.activeElement and DomUtils.isEditable document.activeElement
+ # An input element is already active, so use it.
+ document.activeElement
+ else
+ null
+
+ @push
+ "blur": (event) => @alwaysContinueBubbling =>
+ target = event.target
+ # We can't rely on focus and blur events arriving in the expected order. When the active element
+ # changes, we might get "focus" before "blur". We track the active element in @insertModeLock, and
+ # exit only when that element blurs.
+ @exit event, target if @insertModeLock and target == @insertModeLock
+ "focus": (event) => @alwaysContinueBubbling =>
+ if @insertModeLock != event.target and DomUtils.isFocusable event.target
+ @activateOnElement event.target
+
+ isActive: (event) ->
+ return false if event == InsertMode.suppressedEvent
+ return true if @insertModeLock or @global
+ # Some sites (e.g. inbox.google.com) change the contentEditable property on the fly (see #1245); and
+ # unfortunately, the focus event fires *before* the change. Therefore, we need to re-check whether the
+ # active element is contentEditable.
+ @activateOnElement document.activeElement if document.activeElement?.isContentEditable
+ @insertModeLock != null
+
+ activateOnElement: (element) ->
+ @log "#{@id}: activating (permanent)" if @debug and @permanent
+ @insertModeLock = element
+ Mode.updateBadge()
+
+ exit: (_, target) ->
+ # Note: target == undefined, here, is required only for tests.
+ if (target and target == @insertModeLock) or @global or target == undefined
+ @log "#{@id}: deactivating (permanent)" if @debug and @permanent and @insertModeLock
+ @insertModeLock = null
+ if target and DomUtils.isFocusable target
+ # Remove the focus, so the user can't just get back into insert mode by typing in the same input box.
+ # NOTE(smblott, 2014/12/22) Including embeds for .blur() etc. here is experimental. It appears to be
+ # the right thing to do for most common use cases. However, it could also cripple flash-based sites and
+ # games. See discussion in #1211 and #1194.
+ target.blur()
+ # Exit, but only if this isn't the permanently-installed instance.
+ if @permanent then Mode.updateBadge() else super()
+
+ updateBadge: (badge) ->
+ badge.badge ||= "I" if @isActive badge
+
+ # Static stuff. This allows PostFindMode to suppress the permanently-installed InsertMode instance.
+ @suppressedEvent: null
+ @suppressEvent: (event) -> @suppressedEvent = event
+
+root = exports ? window
+root.InsertMode = InsertMode
diff --git a/content_scripts/mode_passkeys.coffee b/content_scripts/mode_passkeys.coffee
new file mode 100644
index 00000000..94a7c7ec
--- /dev/null
+++ b/content_scripts/mode_passkeys.coffee
@@ -0,0 +1,24 @@
+
+class PassKeysMode extends Mode
+ constructor: ->
+ super
+ name: "passkeys"
+ trackState: true # Maintain @enabled, @passKeys and @keyQueue.
+ keydown: (event) => @handleKeyChar KeyboardUtils.getKeyChar event
+ keypress: (event) => @handleKeyChar String.fromCharCode event.charCode
+ keyup: (event) => @handleKeyChar String.fromCharCode event.charCode
+
+ # Keystrokes are *never* considered passKeys if the keyQueue is not empty. So, for example, if 't' is a
+ # passKey, then 'gt' and '99t' will neverthless be handled by Vimium.
+ handleKeyChar: (keyChar) ->
+ if keyChar and not @keyQueue and 0 <= @passKeys.indexOf keyChar
+ @stopBubblingAndTrue
+ else
+ @continueBubbling
+
+ # Disabled, pending experimentation with how/whether to use badges (smblott, 2015/01/17).
+ # updateBadge: (badge) ->
+ # badge.badge ||= "P" if @passKeys and not @keyQueue
+
+root = exports ? window
+root.PassKeysMode = PassKeysMode
diff --git a/content_scripts/mode_visual.coffee b/content_scripts/mode_visual.coffee
new file mode 100644
index 00000000..2580106d
--- /dev/null
+++ b/content_scripts/mode_visual.coffee
@@ -0,0 +1,20 @@
+
+class VisualMode extends Mode
+ constructor: (element=null) ->
+ super
+ name: "visual"
+ badge: "V"
+ exitOnEscape: true
+ exitOnBlur: element
+
+ keydown: (event) =>
+ return @suppressEvent
+
+ keypress: (event) =>
+ return @suppressEvent
+
+ keyup: (event) =>
+ return @suppressEvent
+
+root = exports ? window
+root.VisualMode = VisualMode
diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee
index 889dc042..6e2e1ffc 100644
--- a/content_scripts/scroller.coffee
+++ b/content_scripts/scroller.coffee
@@ -124,12 +124,15 @@ CoreScroller =
@keyIsDown = false
handlerStack.push
+ _name: 'scroller/track-key-status'
keydown: (event) =>
- @keyIsDown = true
- @lastEvent = event
+ handlerStack.alwaysContinueBubbling =>
+ @keyIsDown = true
+ @lastEvent = event
keyup: =>
- @keyIsDown = false
- @time += 1
+ handlerStack.alwaysContinueBubbling =>
+ @keyIsDown = false
+ @time += 1
# Return true if CoreScroller would not initiate a new scroll right now.
wouldNotInitiateScroll: -> @lastEvent?.repeat and @settings.get "smoothScroll"
@@ -205,7 +208,9 @@ CoreScroller =
# Scroller contains the two main scroll functions (scrollBy and scrollTo) which are exported to clients.
Scroller =
init: (frontendSettings) ->
- handlerStack.push DOMActivate: -> activatedElement = event.target
+ handlerStack.push
+ _name: 'scroller/active-element'
+ DOMActivate: (event) -> handlerStack.alwaysContinueBubbling -> activatedElement = event.target
CoreScroller.init frontendSettings
# scroll the active element in :direction by :amount * :factor.
diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee
index a3ab051b..7121569a 100644
--- a/content_scripts/vimium_frontend.coffee
+++ b/content_scripts/vimium_frontend.coffee
@@ -4,9 +4,8 @@
# background page that we're in domReady and ready to accept normal commands by connectiong to a port named
# "domReady".
#
-window.handlerStack = new HandlerStack
-insertModeLock = null
+targetElement = null
findMode = false
findModeQuery = { rawQuery: "", matchCount: 0 }
findModeQueryHasResults = false
@@ -21,8 +20,8 @@ isEnabledForUrl = true
passKeys = null
keyQueue = null
# The user's operating system.
-currentCompletionKeys = null
-validFirstKeys = null
+currentCompletionKeys = ""
+validFirstKeys = ""
# The types in <input type="..."> that we consider for focusInput command. Right now this is recalculated in
# each content script. Alternatively we could calculate it once in the background page and use a request to
@@ -110,7 +109,21 @@ initializePreDomReady = ->
settings.addEventListener("load", LinkHints.init.bind(LinkHints))
settings.load()
- Scroller.init settings
+ class NormalMode extends Mode
+ constructor: ->
+ super
+ name: "normal"
+ keydown: (event) => onKeydown.call @, event
+ keypress: (event) => onKeypress.call @, event
+ keyup: (event) => onKeyup.call @, event
+
+ Scroller.init settings
+
+ # Install the permanent modes. The permanently-installed insert mode tracks focus/blur events, and
+ # activates/deactivates itself accordingly.
+ new NormalMode
+ new PassKeysMode
+ new InsertMode
checkIfEnabledForUrl()
@@ -136,9 +149,11 @@ initializePreDomReady = ->
getScrollPosition: -> scrollX: window.scrollX, scrollY: window.scrollY
setScrollPosition: (request) -> setScrollPosition request.scrollX, request.scrollY
executePageCommand: executePageCommand
- getActiveState: -> { enabled: isEnabledForUrl, passKeys: passKeys }
+ getActiveState: getActiveState
setState: setState
- currentKeyQueue: (request) -> keyQueue = request.keyQueue
+ currentKeyQueue: (request) ->
+ keyQueue = request.keyQueue
+ handlerStack.bubbleEvent "registerKeyQueue", { keyQueue: keyQueue }
chrome.runtime.onMessage.addListener (request, sender, sendResponse) ->
# In the options page, we will receive requests from both content and background scripts. ignore those
@@ -169,11 +184,8 @@ initializeWhenEnabled = (newPassKeys) ->
if (!installedListeners)
# Key event handlers fire on window before they do on document. Prefer window for key events so the page
# can't set handlers to grab the keys before us.
- installListener window, "keydown", onKeydown
- installListener window, "keypress", onKeypress
- installListener window, "keyup", onKeyup
- installListener document, "focus", onFocusCapturePhase
- installListener document, "blur", onBlurCapturePhase
+ for type in ["keydown", "keypress", "keyup", "click", "focus", "blur"]
+ do (type) -> installListener window, type, (event) -> handlerStack.bubbleEvent type, event
installListener document, "DOMActivate", onDOMActivate
enterInsertModeIfElementIsFocused()
installedListeners = true
@@ -182,6 +194,13 @@ setState = (request) ->
initializeWhenEnabled(request.passKeys) if request.enabled
isEnabledForUrl = request.enabled
passKeys = request.passKeys
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: request.enabled
+ passKeys: request.passKeys
+
+getActiveState = ->
+ Mode.updateBadge()
+ return { enabled: isEnabledForUrl, passKeys: passKeys }
#
# The backend needs to know which frame has focus.
@@ -305,6 +324,12 @@ extend window,
HUD.showForDuration("Yanked URL", 1000)
+ enterInsertMode: ->
+ new InsertMode global: true
+
+ enterVisualMode: =>
+ new VisualMode()
+
focusInput: (count) ->
# Focus the first input element on the page, and create overlays to highlight all the input elements, with
# the currently-focused element highlighted specially. Tabbing will shift focus to the next input element.
@@ -321,10 +346,6 @@ extend window,
selectedInputIndex = Math.min(count - 1, visibleInputs.length - 1)
- visibleInputs[selectedInputIndex].element.focus()
-
- return if visibleInputs.length == 1
-
hints = for tuple in visibleInputs
hint = document.createElement("div")
hint.className = "vimiumReset internalVimiumInputHint vimiumInputHint"
@@ -337,33 +358,43 @@ extend window,
hint
- hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint'
-
- hintContainingDiv = DomUtils.addElementList(hints,
- { id: "vimiumInputMarkerContainer", className: "vimiumReset" })
+ new class FocusSelector extends Mode
+ constructor: ->
+ super
+ name: "focus-selector"
+ badge: "?"
+ # We share a singleton with PostFindMode. That way, a new FocusSelector displaces any existing
+ # PostFindMode.
+ singleton: PostFindMode
+ exitOnClick: true
+ keydown: (event) =>
+ if event.keyCode == KeyboardUtils.keyCodes.tab
+ hints[selectedInputIndex].classList.remove 'internalVimiumSelectedInputHint'
+ selectedInputIndex += hints.length + (if event.shiftKey then -1 else 1)
+ selectedInputIndex %= hints.length
+ hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint'
+ visibleInputs[selectedInputIndex].element.focus()
+ @suppressEvent
+ else unless event.keyCode == KeyboardUtils.keyCodes.shiftKey
+ @exit()
+ @continueBubbling
+
+ @onExit -> DomUtils.removeElement hintContainingDiv
+ hintContainingDiv = DomUtils.addElementList hints,
+ id: "vimiumInputMarkerContainer"
+ className: "vimiumReset"
- handlerStack.push keydown: (event) ->
- if event.keyCode == KeyboardUtils.keyCodes.tab
- hints[selectedInputIndex].classList.remove 'internalVimiumSelectedInputHint'
- if event.shiftKey
- if --selectedInputIndex == -1
- selectedInputIndex = hints.length - 1
- else
- if ++selectedInputIndex == hints.length
- selectedInputIndex = 0
- hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint'
visibleInputs[selectedInputIndex].element.focus()
- else unless event.keyCode == KeyboardUtils.keyCodes.shiftKey
- DomUtils.removeElement hintContainingDiv
- @remove()
- return true
-
- false
+ if visibleInputs.length == 1
+ @exit()
+ else
+ hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint'
# Decide whether this keyChar should be passed to the underlying page.
# Keystrokes are *never* considered passKeys if the keyQueue is not empty. So, for example, if 't' is a
# passKey, then 'gt' and '99t' will neverthless be handled by vimium.
isPassKey = ( keyChar ) ->
+ return false # Disabled.
return !keyQueue and passKeys and 0 <= passKeys.indexOf(keyChar)
# Track which keydown events we have handled, so that we can subsequently suppress the corresponding keyup
@@ -397,9 +428,8 @@ KeydownEvents =
#
# Note that some keys will only register keydown events and not keystroke events, e.g. ESC.
#
+# @/this, here, is the the normal-mode Mode object.
onKeypress = (event) ->
- return unless handlerStack.bubbleEvent('keypress', event)
-
keyChar = ""
# Ignore modifier keys by themselves.
@@ -409,23 +439,27 @@ onKeypress = (event) ->
# Enter insert mode when the user enables the native find interface.
if (keyChar == "f" && KeyboardUtils.isPrimaryModifierKey(event))
enterInsertModeWithoutShowingIndicator()
- return
+ return @stopBubblingAndTrue
if (keyChar)
if (findMode)
handleKeyCharForFindMode(keyChar)
DomUtils.suppressEvent(event)
+ return @stopBubblingAndTrue
else if (!isInsertMode() && !findMode)
if (isPassKey keyChar)
- return undefined
- if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar))
+ return @stopBubblingAndTrue
+ if currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar)
DomUtils.suppressEvent(event)
+ keyPort.postMessage({ keyChar:keyChar, frameId:frameId })
+ return @stopBubblingAndTrue
keyPort.postMessage({ keyChar:keyChar, frameId:frameId })
-onKeydown = (event) ->
- return unless handlerStack.bubbleEvent('keydown', event)
+ return @continueBubbling
+# @/this, here, is the the normal-mode Mode object.
+onKeydown = (event) ->
keyChar = ""
# handle special keys, and normal input keys with modifiers being pressed. don't handle shiftKey alone (to
@@ -464,37 +498,45 @@ onKeydown = (event) ->
exitInsertMode()
DomUtils.suppressEvent event
KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (findMode)
if (KeyboardUtils.isEscape(event))
handleEscapeForFindMode()
DomUtils.suppressEvent event
KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey)
handleDeleteForFindMode()
DomUtils.suppressEvent event
KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (event.keyCode == keyCodes.enter)
handleEnterForFindMode()
DomUtils.suppressEvent event
KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (!modifiers)
DomUtils.suppressPropagation(event)
KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (isShowingHelpDialog && KeyboardUtils.isEscape(event))
hideHelpDialog()
DomUtils.suppressEvent event
KeydownEvents.push event
+ return @stopBubblingAndTrue
else if (!isInsertMode() && !findMode)
if (keyChar)
if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar))
DomUtils.suppressEvent event
KeydownEvents.push event
+ keyPort.postMessage({ keyChar:keyChar, frameId:frameId })
+ return @stopBubblingAndTrue
keyPort.postMessage({ keyChar:keyChar, frameId:frameId })
@@ -516,13 +558,15 @@ onKeydown = (event) ->
isValidFirstKey(KeyboardUtils.getKeyChar(event))))
DomUtils.suppressPropagation(event)
KeydownEvents.push event
+ return @stopBubblingAndTrue
-onKeyup = (event) ->
- handledKeydown = KeydownEvents.pop event
- return unless handlerStack.bubbleEvent("keyup", event)
+ return @continueBubbling
- # Don't propagate the keyup to the underlying page if Vimium has handled it. See #733.
- DomUtils.suppressPropagation(event) if handledKeydown
+# @/this, here, is the the normal-mode Mode object.
+onKeyup = (event) ->
+ return @continueBubbling unless KeydownEvents.pop event
+ DomUtils.suppressPropagation(event)
+ @stopBubblingAndTrue
checkIfEnabledForUrl = ->
url = window.location.toString()
@@ -534,8 +578,12 @@ checkIfEnabledForUrl = ->
else if (HUD.isReady())
# Quickly hide any HUD we might already be showing, e.g. if we entered insert mode on page load.
HUD.hide()
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: response.isEnabledForUrl
+ passKeys: response.passKeys
-refreshCompletionKeys = (response) ->
+# Exported to window, but only for DOM tests.
+window.refreshCompletionKeys = (response) ->
if (response)
currentCompletionKeys = response.completionKeys
@@ -583,35 +631,21 @@ isEditable = (target) ->
focusableElements.indexOf(nodeName) >= 0
#
-# Enters insert mode and show an "Insert mode" message. Showing the UI is only useful when entering insert
-# mode manually by pressing "i". In most cases we do not show any UI (enterInsertModeWithoutShowingIndicator)
-#
-window.enterInsertMode = (target) ->
- enterInsertModeWithoutShowingIndicator(target)
- HUD.show("Insert mode")
-
-#
# We cannot count on 'focus' and 'blur' events to happen sequentially. For example, if blurring element A
# causes element B to come into focus, we may get "B focus" before "A blur". Thus we only leave insert mode
-# when the last editable element that came into focus -- which insertModeLock points to -- has been blurred.
-# If insert mode is entered manually (via pressing 'i'), then we set insertModeLock to 'undefined', and only
+# when the last editable element that came into focus -- which targetElement points to -- has been blurred.
+# If insert mode is entered manually (via pressing 'i'), then we set targetElement to 'undefined', and only
# leave insert mode when the user presses <ESC>.
# Note. This returns the truthiness of target, which is required by isInsertMode.
#
-enterInsertModeWithoutShowingIndicator = (target) -> insertModeLock = target
+enterInsertModeWithoutShowingIndicator = (target) ->
+ return # Disabled.
exitInsertMode = (target) ->
- if (target == undefined || insertModeLock == target)
- insertModeLock = null
- HUD.hide()
+ return # Disabled.
isInsertMode = ->
- return true if insertModeLock != null
- # Some sites (e.g. inbox.google.com) change the contentEditable attribute on the fly (see #1245); and
- # unfortunately, isEditable() is called *before* the change is made. Therefore, we need to re-check whether
- # the active element is contentEditable.
- document.activeElement and document.activeElement.isContentEditable and
- enterInsertModeWithoutShowingIndicator document.activeElement
+ return false # Disabled.
# should be called whenever rawQuery is modified.
updateFindModeQuery = ->
@@ -701,6 +735,41 @@ handleEnterForFindMode = ->
document.body.classList.add("vimiumFindMode")
settings.set("findModeRawQuery", findModeQuery.rawQuery)
+class FindMode extends Mode
+ constructor: ->
+ super
+ name: "find"
+ badge: "/"
+ exitOnEscape: true
+ exitOnClick: true
+
+ keydown: (event) =>
+ if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey
+ handleDeleteForFindMode()
+ @suppressEvent
+ else if event.keyCode == keyCodes.enter
+ handleEnterForFindMode()
+ @exit()
+ @suppressEvent
+ else
+ DomUtils.suppressPropagation(event)
+ handlerStack.stopBubblingAndFalse
+
+ keypress: (event) ->
+ handlerStack.neverContinueBubbling ->
+ if event.keyCode > 31
+ keyChar = String.fromCharCode event.charCode
+ handleKeyCharForFindMode keyChar if keyChar
+
+ keyup: (event) => @suppressEvent
+
+ exit: (event) ->
+ super()
+ handleEscapeForFindMode() if event?.type == "keydown" and KeyboardUtils.isEscape event
+ handleEscapeForFindMode() if event?.type == "click"
+ if findModeQueryHasResults and event?.type != "click"
+ new PostFindMode
+
performFindInPlace = ->
cachedScrollX = window.scrollX
cachedScrollY = window.scrollY
@@ -719,13 +788,9 @@ performFindInPlace = ->
# :options is an optional dict. valid parameters are 'caseSensitive' and 'backwards'.
executeFind = (query, options) ->
+ result = null
options = options || {}
- # rather hacky, but this is our way of signalling to the insertMode listener not to react to the focus
- # changes that find() induces.
- oldFindMode = findMode
- findMode = true
-
document.body.classList.add("vimiumFindMode")
# prevent find from matching its own search query in the HUD
@@ -737,7 +802,13 @@ executeFind = (query, options) ->
-> document.addEventListener("selectionchange", restoreDefaultSelectionHighlight, true)
0)
- findMode = oldFindMode
+ # We are either in normal mode ("n"), or find mode ("/"). We are not in insert mode. Nevertheless, if a
+ # previous find landed in an editable element, then that element may still be activated. In this case, we
+ # don't want to leave it behind (see #1412).
+ if document.activeElement and DomUtils.isEditable document.activeElement
+ if not DomUtils.isSelected document.activeElement
+ document.activeElement.blur()
+
# we need to save the anchor node here because <esc> seems to nullify it, regardless of whether we do
# preventDefault()
findModeAnchorNode = document.getSelection().anchorNode
@@ -750,13 +821,6 @@ focusFoundLink = ->
link = getLinkFromSelection()
link.focus() if link
-isDOMDescendant = (parent, child) ->
- node = child
- while (node != null)
- return true if (node == parent)
- node = node.parentNode
- false
-
selectFoundInputElement = ->
# if the found text is in an input element, getSelection().anchorNode will be null, so we use activeElement
# instead. however, since the last focused element might not be the one currently pointed to by find (e.g.
@@ -764,7 +828,7 @@ selectFoundInputElement = ->
# heuristic of checking that the last anchor node is an ancestor of our element.
if (findModeQueryHasResults && document.activeElement &&
DomUtils.isSelectable(document.activeElement) &&
- isDOMDescendant(findModeAnchorNode, document.activeElement))
+ DomUtils.isDOMDescendant(findModeAnchorNode, document.activeElement))
DomUtils.simulateSelect(document.activeElement)
# the element has already received focus via find(), so invoke insert mode manually
enterInsertModeWithoutShowingIndicator(document.activeElement)
@@ -795,27 +859,11 @@ findAndFocus = (backwards) ->
findModeQueryHasResults =
executeFind(query, { backwards: backwards, caseSensitive: !findModeQuery.ignoreCase })
- if (!findModeQueryHasResults)
+ if findModeQueryHasResults
+ focusFoundLink()
+ new PostFindMode() if findModeQueryHasResults
+ else
HUD.showForDuration("No matches for '" + findModeQuery.rawQuery + "'", 1000)
- return
-
- # if we have found an input element via 'n', pressing <esc> immediately afterwards sends us into insert
- # mode
- elementCanTakeInput = document.activeElement &&
- DomUtils.isSelectable(document.activeElement) &&
- isDOMDescendant(findModeAnchorNode, document.activeElement)
- if (elementCanTakeInput)
- handlerStack.push({
- keydown: (event) ->
- @remove()
- if (KeyboardUtils.isEscape(event))
- DomUtils.simulateSelect(document.activeElement)
- enterInsertModeWithoutShowingIndicator(document.activeElement)
- return false # we have "consumed" this event, so do not propagate
- return true
- })
-
- focusFoundLink()
window.performFind = -> findAndFocus()
@@ -930,11 +978,10 @@ showFindModeHUDForQuery = ->
window.enterFindMode = ->
findModeQuery = { rawQuery: "" }
- findMode = true
HUD.show("/")
+ new FindMode()
exitFindMode = ->
- findMode = false
HUD.hide()
window.showHelpDialog = (html, fid) ->
diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee
index 7a75dd6a..aee2f972 100644
--- a/lib/dom_utils.coffee
+++ b/lib/dom_utils.coffee
@@ -142,6 +142,39 @@ DomUtils =
(element.nodeName.toLowerCase() == "input" && unselectableTypes.indexOf(element.type) == -1) ||
element.nodeName.toLowerCase() == "textarea"
+ # Input or text elements are considered focusable and able to receieve their own keyboard events, and will
+ # enter insert mode if focused. Also note that the "contentEditable" attribute can be set on any element
+ # which makes it a rich text editor, like the notes on jjot.com.
+ isEditable: (element) ->
+ return true if element.isContentEditable
+ nodeName = element.nodeName?.toLowerCase()
+ # Use a blacklist instead of a whitelist because new form controls are still being implemented for html5.
+ if nodeName == "input" and element.type not in ["radio", "checkbox"]
+ return true
+ nodeName in ["textarea", "select"]
+
+ # Embedded elements like Flash and quicktime players can obtain focus.
+ isEmbed: (element) ->
+ element.nodeName?.toLowerCase() in ["embed", "object"]
+
+ isFocusable: (element) ->
+ @isEditable(element) or @isEmbed element
+
+ isDOMDescendant: (parent, child) ->
+ node = child
+ while (node != null)
+ return true if (node == parent)
+ node = node.parentNode
+ false
+
+ # True if element contains the active selection range.
+ isSelected: (element) ->
+ if element.isContentEditable
+ node = document.getSelection()?.anchorNode
+ node and @isDOMDescendant element, node
+ else
+ element.selectionStart? and element.selectionEnd? and element.selectionStart != element.selectionEnd
+
simulateSelect: (element) ->
element.focus()
# When focusing a textbox, put the selection caret at the end of the textbox's contents.
@@ -179,5 +212,14 @@ DomUtils =
event.preventDefault()
@suppressPropagation(event)
+ # Suppress the next keyup event for Escape.
+ suppressKeyupAfterEscape: (handlerStack) ->
+ handlerStack.push
+ _name: "dom_utils/suppressKeyupAfterEscape"
+ keyup: (event) ->
+ return true unless KeyboardUtils.isEscape event
+ @remove()
+ false
+
root = exports ? window
root.DomUtils = DomUtils
diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee
index 858f2ec9..76d835b7 100644
--- a/lib/handler_stack.coffee
+++ b/lib/handler_stack.coffee
@@ -1,37 +1,99 @@
root = exports ? window
-class root.HandlerStack
+class HandlerStack
constructor: ->
+ @debug = false
+ @eventNumber = 0
@stack = []
@counter = 0
- genId: -> @counter = ++@counter & 0xffff
+ # A handler should return this value to immediately discontinue bubbling and pass the event on to the
+ # underlying page.
+ @stopBubblingAndTrue = new Object()
- # Adds a handler to the stack. Returns a unique ID for that handler that can be used to remove it later.
+ # A handler should return this value to indicate that the event has been consumed, and no further
+ # processing should take place. The event does not propagate to the underlying page.
+ @stopBubblingAndFalse = new Object()
+
+ # A handler should return this value to indicate that bubbling should be restarted. Typically, this is
+ # used when, while bubbling an event, a new mode is pushed onto the stack.
+ @restartBubbling = new Object()
+
+ # Adds a handler to the top of the stack. Returns a unique ID for that handler that can be used to remove it
+ # later.
push: (handler) ->
- handler.id = @genId()
+ handler._name ||= "anon-#{@counter}"
@stack.push handler
- handler.id
+ handler.id = ++@counter
+
+ # As above, except the new handler is added to the bottom of the stack.
+ unshift: (handler) ->
+ handler._name ||= "anon-#{@counter}"
+ handler._name += "/unshift"
+ @stack.unshift handler
+ handler.id = ++@counter
- # Called whenever we receive a key event. Each individual handler has the option to stop the event's
- # propagation by returning a falsy value.
+ # Called whenever we receive a key or other event. Each individual handler has the option to stop the
+ # event's propagation by returning a falsy value, or stop bubbling by returning @stopBubblingAndFalse or
+ # @stopBubblingAndTrue.
bubbleEvent: (type, event) ->
- for i in [(@stack.length - 1)..0] by -1
- handler = @stack[i]
- # We need to check for existence of handler because the last function call may have caused the release
- # of more than one handler.
- if handler && handler[type]
+ @eventNumber += 1
+ # We take a copy of the array in order to avoid interference from concurrent removes (for example, to
+ # avoid calling the same handler twice, because elements have been spliced out of the array by remove).
+ for handler in @stack[..].reverse()
+ # A handler may have been removed (handler.id == null), so check.
+ if handler?.id and handler[type]
@currentId = handler.id
- passThrough = handler[type].call(@, event)
- if not passThrough
- DomUtils.suppressEvent(event)
+ result = handler[type].call @, event
+ @logResult type, event, handler, result if @debug
+ if not result
+ DomUtils.suppressEvent event if @isChromeEvent event
return false
+ return true if result == @stopBubblingAndTrue
+ return false if result == @stopBubblingAndFalse
+ return @bubbleEvent type, event if result == @restartBubbling
true
remove: (id = @currentId) ->
for i in [(@stack.length - 1)..0] by -1
handler = @stack[i]
if handler.id == id
+ # Mark the handler as removed.
+ handler.id = null
@stack.splice(i, 1)
break
+
+ # The handler stack handles chrome events (which may need to be suppressed) and internal (pseudo) events.
+ # This checks whether the event at hand is a chrome event.
+ isChromeEvent: (event) ->
+ event?.preventDefault? or event?.stopImmediatePropagation?
+
+ # Convenience wrappers. Handlers must return an approriate value. These are wrappers which handlers can
+ # use to always return the same value. This then means that the handler itself can be implemented without
+ # regard to its return value.
+ alwaysContinueBubbling: (handler) ->
+ handler()
+ true
+
+ neverContinueBubbling: (handler) ->
+ handler()
+ false
+
+ # Debugging.
+ logResult: (type, event, handler, result) ->
+ # FIXME(smblott). Badge updating is too noisy, so we filter it out. However, we do need to look at how
+ # many badge update events are happening. It seems to be more than necessary. We also filter out
+ # registerKeyQueue as unnecessarily noisy and not particularly helpful.
+ return if type in [ "updateBadge", "registerKeyQueue" ]
+ label =
+ switch result
+ when @stopBubblingAndTrue then "stop/true"
+ when @stopBubblingAndFalse then "stop/false"
+ when @restartBubbling then "rebubble"
+ when true then "continue"
+ label ||= if result then "continue/truthy" else "suppress"
+ console.log "#{@eventNumber}", type, handler._name, label
+
+root.HandlerStack = HandlerStack
+root.handlerStack = new HandlerStack()
diff --git a/lib/keyboard_utils.coffee b/lib/keyboard_utils.coffee
index d2a843f9..30d99656 100644
--- a/lib/keyboard_utils.coffee
+++ b/lib/keyboard_utils.coffee
@@ -55,6 +55,12 @@ KeyboardUtils =
# c-[ is mapped to ESC in Vim by default.
(event.keyCode == @keyCodes.ESC) || (event.ctrlKey && @getKeyChar(event) == '[')
+ # TODO. This is probably a poor way of detecting printable characters. However, it shouldn't incorrectly
+ # identify any of chrome's own keyboard shortcuts as printable.
+ isPrintable: (event) ->
+ return false if event.metaKey or event.ctrlKey or event.altKey
+ @getKeyChar(event)?.length == 1
+
KeyboardUtils.init()
root = exports ? window
diff --git a/manifest.json b/manifest.json
index a365f390..a04d8c0e 100644
--- a/manifest.json
+++ b/manifest.json
@@ -43,6 +43,11 @@
"content_scripts/vomnibar.js",
"content_scripts/scroller.js",
"content_scripts/marks.js",
+ "content_scripts/mode.js",
+ "content_scripts/mode_insert.js",
+ "content_scripts/mode_passkeys.js",
+ "content_scripts/mode_find.js",
+ "content_scripts/mode_visual.js",
"content_scripts/vimium_frontend.js"
],
"css": ["content_scripts/vimium.css"],
diff --git a/tests/dom_tests/dom_tests.coffee b/tests/dom_tests/dom_tests.coffee
index 4a61877c..c73e0885 100644
--- a/tests/dom_tests/dom_tests.coffee
+++ b/tests/dom_tests/dom_tests.coffee
@@ -8,10 +8,23 @@ mockKeyboardEvent = (keyChar) ->
event.charCode = (if keyCodes[keyChar] isnt undefined then keyCodes[keyChar] else keyChar.charCodeAt(0))
event.keyIdentifier = "U+00" + event.charCode.toString(16)
event.keyCode = event.charCode
- event.stopImmediatePropagation = ->
- event.preventDefault = ->
+ event.stopImmediatePropagation = -> @suppressed = true
+ event.preventDefault = -> @suppressed = true
event
+# Some of these tests have side effects on the handler stack and active mode. Therefore, we take backups and
+# restore them on tear down.
+backupStackState = ->
+ Mode.backup = Mode.modes[..]
+ InsertMode.permanentInstance.exit()
+ handlerStack.backup = handlerStack.stack[..]
+restoreStackState = ->
+ for mode in Mode.modes
+ mode.exit() unless mode in Mode.backup
+ Mode.modes = Mode.backup
+ InsertMode.permanentInstance.exit()
+ handlerStack.stack = handlerStack.backup
+
#
# Retrieve the hint markers as an array object.
#
@@ -170,9 +183,11 @@ context "Input focus",
testContent = "<input type='text' id='first'/><input style='display:none;' id='second'/>
<input type='password' id='third' value='some value'/>"
document.getElementById("test-div").innerHTML = testContent
+ backupStackState()
tearDown ->
document.getElementById("test-div").innerHTML = ""
+ restoreStackState()
should "focus the right element", ->
focusInput 1
@@ -184,6 +199,16 @@ context "Input focus",
assert.equal "third", document.activeElement.id
handlerStack.bubbleEvent 'keydown', mockKeyboardEvent("A")
+ # This is the same as above, but also verifies that focusInput activates insert mode.
+ should "activate insert mode", ->
+ focusInput 1
+ handlerStack.bubbleEvent 'focus', { target: document.activeElement }
+ assert.isTrue InsertMode.permanentInstance.isActive()
+
+ focusInput 100
+ handlerStack.bubbleEvent 'focus', { target: document. activeElement }
+ assert.isTrue InsertMode.permanentInstance.isActive()
+
# TODO: these find prev/next link tests could be refactored into unit tests which invoke a function which has
# a tighter contract than goNext(), since they test minor aspects of goNext()'s link matching behavior, and we
# don't need to construct external state many times over just to test that.
@@ -243,9 +268,429 @@ context "Find prev / next links",
goNext()
assert.equal '#first', window.location.hash
-
createLinks = (n) ->
for i in [0...n] by 1
link = document.createElement("a")
link.textContent = "test"
document.getElementById("test-div").appendChild link
+
+# For these tests, we use "m" as a mapped key, "p" as a pass key, and "u" as an unmapped key.
+context "Normal mode",
+ setup ->
+ document.activeElement?.blur()
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "m"
+
+ tearDown ->
+ restoreStackState()
+
+ should "suppress mapped keys", ->
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "m"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+ should "not suppress unmapped keys", ->
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+context "Passkeys mode",
+ setup ->
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "mp"
+
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: ""
+
+ handlerStack.bubbleEvent "registerKeyQueue",
+ keyQueue: ""
+
+ tearDown ->
+ restoreStackState()
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: ""
+
+ handlerStack.bubbleEvent "registerKeyQueue",
+ keyQueue: ""
+
+ should "not suppress passKeys", ->
+ # First check normal-mode key (just to verify the framework).
+ for k in [ "m", "p" ]
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "p"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+ # Install passKey.
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: "p"
+
+ # Then verify passKey.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "p"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+ # And re-verify a mapped key.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "m"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+ should "suppress passKeys with a non-empty keyQueue", ->
+ # Install passKey.
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: "p"
+
+ # First check the key is indeed not suppressed.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "p"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+ handlerStack.bubbleEvent "registerKeyQueue",
+ keyQueue: "1"
+
+ # Now verify that the key is suppressed.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "p"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+context "Insert mode",
+ setup ->
+ document.activeElement?.blur()
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "m"
+
+ tearDown ->
+ backupStackState()
+
+ should "not suppress mapped keys in insert mode", ->
+ # First verify normal-mode key (just to verify the framework).
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "m"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+ # Install insert mode.
+ insertMode = new InsertMode
+ global: true
+
+ # Then verify insert mode.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "m"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+ insertMode.exit()
+
+ # Then verify that insert mode has been successfully removed.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "m"
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+context "Triggering insert mode",
+ setup ->
+ document.activeElement?.blur()
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "m"
+
+ testContent = "<input type='text' id='first'/>
+ <input style='display:none;' id='second'/>
+ <input type='password' id='third' value='some value'/>"
+ document.getElementById("test-div").innerHTML = testContent
+
+ tearDown ->
+ restoreStackState()
+ document.getElementById("test-div").innerHTML = ""
+
+ should "trigger insert mode on focus of contentEditable elements", ->
+ handlerStack.bubbleEvent "focus",
+ target:
+ isContentEditable: true
+
+ assert.isTrue Mode.top().name == "insert" and Mode.top().isActive()
+
+ should "trigger insert mode on focus of text input", ->
+ document.getElementById("first").focus()
+ handlerStack.bubbleEvent "focus", { target: document.activeElement }
+
+ assert.isTrue Mode.top().name == "insert" and Mode.top().isActive()
+
+ should "trigger insert mode on focus of password input", ->
+ document.getElementById("third").focus()
+ handlerStack.bubbleEvent "focus", { target: document.activeElement }
+
+ assert.isTrue Mode.top().name == "insert" and Mode.top().isActive()
+
+ should "not handle suppressed events", ->
+ document.getElementById("first").focus()
+ handlerStack.bubbleEvent "focus", { target: document.activeElement }
+ assert.isTrue Mode.top().name == "insert" and Mode.top().isActive()
+
+ for event in [ "keydown", "keypress", "keyup" ]
+ # Because "m" is mapped, we expect insert mode to ignore it, and normal mode to suppress it.
+ key = mockKeyboardEvent "m"
+ InsertMode.suppressEvent key
+ handlerStack.bubbleEvent event, key
+ assert.isTrue key.suppressed
+
+
+context "Mode utilities",
+ setup ->
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "m"
+
+ testContent = "<input type='text' id='first'/>
+ <input style='display:none;' id='second'/>
+ <input type='password' id='third' value='some value'/>"
+ document.getElementById("test-div").innerHTML = testContent
+
+ tearDown ->
+ restoreStackState()
+ document.getElementById("test-div").innerHTML = ""
+
+ should "not have duplicate singletons", ->
+ count = 0
+
+ class Test extends Mode
+ constructor: ->
+ count += 1
+ super
+ singleton: Test
+
+ exit: ->
+ count -= 1
+ super()
+
+ assert.isTrue count == 0
+ for [1..10]
+ mode = new Test(); assert.isTrue count == 1
+
+ mode.exit()
+ assert.isTrue count == 0
+
+ should "exit on escape", ->
+ escape =
+ keyCode: 27
+
+ new Mode
+ exitOnEscape: true
+ name: "test"
+
+ assert.isTrue Mode.top().name == "test"
+ handlerStack.bubbleEvent "keydown", escape
+ assert.isTrue Mode.top().name != "test"
+
+ should "not exit on escape if not enabled", ->
+ escape =
+ keyCode: 27
+ keyIdentifier: ""
+ stopImmediatePropagation: ->
+
+ new Mode
+ exitOnEscape: false
+ name: "test"
+
+ assert.isTrue Mode.top().name == "test"
+ handlerStack.bubbleEvent "keydown", escape
+ assert.isTrue Mode.top().name == "test"
+
+ should "exit on blur", ->
+ element = document.getElementById("first")
+ element.focus()
+
+ new Mode
+ exitOnBlur: element
+ name: "test"
+
+ assert.isTrue Mode.top().name == "test"
+ handlerStack.bubbleEvent "blur", { target: element }
+ assert.isTrue Mode.top().name != "test"
+
+ should "not exit on blur if not enabled", ->
+ element = document.getElementById("first")
+ element.focus()
+
+ new Mode
+ exitOnBlur: null
+ name: "test"
+
+ assert.isTrue Mode.top().name == "test"
+ handlerStack.bubbleEvent "blur", { target: element }
+ assert.isTrue Mode.top().name == "test"
+
+ should "register state change", ->
+ enabled = null
+ passKeys = null
+
+ class Test extends Mode
+ constructor: ->
+ super
+ trackState: true
+
+ registerStateChange: ->
+ enabled = @enabled
+ passKeys = @passKeys
+
+ new Test()
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: "enabled"
+ passKeys: "passKeys"
+ assert.isTrue enabled == "enabled"
+ assert.isTrue passKeys == "passKeys"
+
+ should "suppress printable keys", ->
+ element = document.getElementById("first")
+ element.focus()
+ handlerStack.bubbleEvent "focus", { target: document.activeElement }
+
+ # Verify that a key is not suppressed.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+ new PostFindMode {}
+
+ # Verify that the key is now suppressed for keypress.
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent "keypress",
+ extend key,
+ srcElement: element
+ assert.isTrue key.suppressed
+
+ # Verify key is not suppressed with Control key.
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent "keypress",
+ extend key,
+ srcElement: element
+ ctrlKey: true
+ assert.isFalse key.suppressed
+
+ # Verify key is not suppressed with Meta key.
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent "keypress",
+ extend key,
+ srcElement: element
+ metaKey: true
+ assert.isFalse key.suppressed
+
+context "PostFindMode",
+ setup ->
+ backupStackState()
+ refreshCompletionKeys
+ completionKeys: "m"
+
+ testContent = "<input type='text' id='first'/>
+ <input style='display:none;' id='second'/>
+ <input type='password' id='third' value='some value'/>"
+ document.getElementById("test-div").innerHTML = testContent
+
+ @escape =
+ keyCode: 27
+ keyIdentifier: ""
+ stopImmediatePropagation: ->
+ preventDefault: ->
+
+ @element = document.getElementById("first")
+ @element.focus()
+ handlerStack.bubbleEvent "focus", { target: document.activeElement }
+
+ tearDown ->
+ restoreStackState()
+ document.getElementById("test-div").innerHTML = ""
+
+ should "be a singleton", ->
+ count = 0
+
+ assert.isTrue Mode.top().name == "insert"
+ new PostFindMode @element
+ assert.isTrue Mode.top().name == "post-find"
+ new PostFindMode @element
+ assert.isTrue Mode.top().name == "post-find"
+ Mode.top().exit()
+ assert.isTrue Mode.top().name == "insert"
+
+ should "suppress unmapped printable keypress events", ->
+ # Verify key is passed through.
+ for event in [ "keydown", "keypress", "keyup" ]
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent event, key
+ assert.isFalse key.suppressed
+
+ new PostFindMode @element
+
+ # Verify key is now suppressed for keypress.
+ key = mockKeyboardEvent "u"
+ handlerStack.bubbleEvent "keypress",
+ extend key,
+ srcElement: @element
+ assert.isTrue key.suppressed
+
+ should "be clickable to focus", ->
+ new PostFindMode @element
+
+ assert.isTrue Mode.top().name != "insert"
+ handlerStack.bubbleEvent "click", { target: document.activeElement }
+ assert.isTrue Mode.top().name == "insert"
+
+ should "enter insert mode on immediate escape", ->
+
+ new PostFindMode @element
+ assert.isTrue Mode.top().name == "post-find"
+ handlerStack.bubbleEvent "keydown", @escape
+ assert.isTrue Mode.top().name == "insert"
+
+ should "not enter insert mode on subsequent escape", ->
+ new PostFindMode @element
+ assert.isTrue Mode.top().name == "post-find"
+ handlerStack.bubbleEvent "keydown", mockKeyboardEvent "u"
+ handlerStack.bubbleEvent "keydown", @escape
+ assert.isTrue Mode.top().name == "post-find"
+
+context "Mode badges",
+ setup ->
+ backupStackState()
+
+ tearDown ->
+ restoreStackState()
+
+ should "have no badge without passKeys", ->
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: ""
+
+ handlerStack.bubbleEvent "updateBadge", badge = { badge: "" }
+ assert.isTrue badge.badge == ""
+
+ should "have no badge with passKeys", ->
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: true
+ passKeys: "p"
+
+ handlerStack.bubbleEvent "updateBadge", badge = { badge: "" }
+ assert.isTrue badge.badge == ""
+
+ should "have no badge when disabled", ->
+ handlerStack.bubbleEvent "registerStateChange",
+ enabled: false
+ passKeys: ""
+
+ new InsertMode()
+ handlerStack.bubbleEvent "updateBadge", badge = { badge: "" }
+ assert.isTrue badge.badge == ""
+
diff --git a/tests/dom_tests/dom_tests.html b/tests/dom_tests/dom_tests.html
index a764b42d..33759abd 100644
--- a/tests/dom_tests/dom_tests.html
+++ b/tests/dom_tests/dom_tests.html
@@ -39,6 +39,11 @@
<script type="text/javascript" src="../../content_scripts/link_hints.js"></script>
<script type="text/javascript" src="../../content_scripts/vomnibar.js"></script>
<script type="text/javascript" src="../../content_scripts/scroller.js"></script>
+ <script type="text/javascript" src="../../content_scripts/mode.js"></script>
+ <script type="text/javascript" src="../../content_scripts/mode_passkeys.js"></script>
+ <script type="text/javascript" src="../../content_scripts/mode_insert.js"></script>
+ <script type="text/javascript" src="../../content_scripts/mode_find.js"></script>
+ <script type="text/javascript" src="../../content_scripts/mode_visual.js"></script>
<script type="text/javascript" src="../../content_scripts/vimium_frontend.js"></script>
<script type="text/javascript" src="../shoulda.js/shoulda.js"></script>
diff --git a/tests/unit_tests/handler_stack_test.coffee b/tests/unit_tests/handler_stack_test.coffee
index 0ed8f4c0..0ed85e63 100644
--- a/tests/unit_tests/handler_stack_test.coffee
+++ b/tests/unit_tests/handler_stack_test.coffee
@@ -23,6 +23,29 @@ context "handlerStack",
assert.isTrue @handler2Called
assert.isFalse @handler1Called
+ should "terminate bubbling on stopBubblingAndTrue, and be true", ->
+ @handlerStack.push { keydown: => @handler1Called = true }
+ @handlerStack.push { keydown: => @handler2Called = true; @handlerStack.stopBubblingAndTrue }
+ assert.isTrue @handlerStack.bubbleEvent 'keydown', {}
+ assert.isTrue @handler2Called
+ assert.isFalse @handler1Called
+
+ should "terminate bubbling on stopBubblingAndTrue, and be false", ->
+ @handlerStack.push { keydown: => @handler1Called = true }
+ @handlerStack.push { keydown: => @handler2Called = true; @handlerStack.stopBubblingAndFalse }
+ assert.isFalse @handlerStack.bubbleEvent 'keydown', {}
+ assert.isTrue @handler2Called
+ assert.isFalse @handler1Called
+
+ should "restart bubbling on restartBubbling", ->
+ @handler1Called = 0
+ @handler2Called = 0
+ id = @handlerStack.push { keydown: => @handler1Called++; @handlerStack.remove(id); @handlerStack.restartBubbling }
+ @handlerStack.push { keydown: => @handler2Called++; true }
+ assert.isTrue @handlerStack.bubbleEvent 'keydown', {}
+ assert.isTrue @handler1Called == 1
+ assert.isTrue @handler2Called == 2
+
should "remove handlers correctly", ->
@handlerStack.push { keydown: => @handler1Called = true }
handlerId = @handlerStack.push { keydown: => @handler2Called = true }
diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee
index 3258bcd6..7f666068 100644
--- a/tests/unit_tests/test_chrome_stubs.coffee
+++ b/tests/unit_tests/test_chrome_stubs.coffee
@@ -41,6 +41,8 @@ exports.chrome =
addListener: () -> true
getAll: () -> true
+ browserAction:
+ setBadgeBackgroundColor: ->
storage:
# chrome.storage.local
local: