diff options
Diffstat (limited to 'content_scripts')
| -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 | 
8 files changed, 572 insertions, 121 deletions
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) ->  | 
