diff options
| -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: | 
