diff options
| author | Stephen Blott | 2015-01-18 10:39:09 +0000 |
|---|---|---|
| committer | Stephen Blott | 2015-01-18 10:39:09 +0000 |
| commit | a1edae57e2847c2b6ffcae60ea8c9c16216e4692 (patch) | |
| tree | 30ff186038028f9d0c0d5cc08d572ca56dda8819 | |
| parent | 8c9e429074580ea20aba662ee430d87bd73ebc4b (diff) | |
| parent | 5d087c89917e21872711b7b908fcdd3c7e9e7f17 (diff) | |
| download | vimium-a1edae57e2847c2b6ffcae60ea8c9c16216e4692.tar.bz2 | |
Merge pull request #1413 from smblott-github/modes
A modal-browsing framework
| -rw-r--r-- | background_scripts/commands.coffee | 3 | ||||
| -rw-r--r-- | background_scripts/main.coffee | 21 | ||||
| -rw-r--r-- | content_scripts/link_hints.coffee | 26 | ||||
| -rw-r--r-- | content_scripts/mode.coffee | 202 | ||||
| -rw-r--r-- | content_scripts/mode_find.coffee | 66 | ||||
| -rw-r--r-- | content_scripts/mode_insert.coffee | 83 | ||||
| -rw-r--r-- | content_scripts/mode_passkeys.coffee | 24 | ||||
| -rw-r--r-- | content_scripts/mode_visual.coffee | 20 | ||||
| -rw-r--r-- | content_scripts/scroller.coffee | 15 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 257 | ||||
| -rw-r--r-- | lib/dom_utils.coffee | 42 | ||||
| -rw-r--r-- | lib/handler_stack.coffee | 92 | ||||
| -rw-r--r-- | lib/keyboard_utils.coffee | 6 | ||||
| -rw-r--r-- | manifest.json | 5 | ||||
| -rw-r--r-- | tests/dom_tests/dom_tests.coffee | 451 | ||||
| -rw-r--r-- | tests/dom_tests/dom_tests.html | 5 | ||||
| -rw-r--r-- | tests/unit_tests/handler_stack_test.coffee | 23 | ||||
| -rw-r--r-- | tests/unit_tests/test_chrome_stubs.coffee | 2 |
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: |
