diff options
| author | Stephen Blott | 2015-01-18 10:39:09 +0000 |
|---|---|---|
| committer | Stephen Blott | 2015-01-18 10:39:09 +0000 |
| commit | a1edae57e2847c2b6ffcae60ea8c9c16216e4692 (patch) | |
| tree | 30ff186038028f9d0c0d5cc08d572ca56dda8819 /content_scripts | |
| parent | 8c9e429074580ea20aba662ee430d87bd73ebc4b (diff) | |
| parent | 5d087c89917e21872711b7b908fcdd3c7e9e7f17 (diff) | |
| download | vimium-a1edae57e2847c2b6ffcae60ea8c9c16216e4692.tar.bz2 | |
Merge pull request #1413 from smblott-github/modes
A modal-browsing framework
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) -> |
