diff options
| -rw-r--r-- | content_scripts/mode.coffee | 201 | ||||
| -rw-r--r-- | content_scripts/mode_find.coffee | 19 | ||||
| -rw-r--r-- | content_scripts/mode_insert.coffee | 59 | ||||
| -rw-r--r-- | content_scripts/mode_passkeys.coffee | 17 | ||||
| -rw-r--r-- | content_scripts/mode_visual.coffee | 7 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 13 | ||||
| -rw-r--r-- | lib/handler_stack.coffee | 23 |
7 files changed, 149 insertions, 190 deletions
diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee index e9a4a621..92285b8c 100644 --- a/content_scripts/mode.coffee +++ b/content_scripts/mode.coffee @@ -1,50 +1,46 @@ -# Modes. # # A mode implements a number of keyboard event handlers which are pushed onto the handler stack when the mode -# starts, and poped when the mode exits. The Mode base class takes as single argument options which can -# define: +# 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) for this mode. -# Optional. Define a badge is the badge is constant. Otherwise, do not define a badge and override the -# chooseBadge method instead. Or, if the mode *never* shows a badge, then do neither. +# A badge (to appear on the browser popup). +# Optional. Define a badge if the badge is constant. Otherwise, do not define a badge, but override +# instead the chooseBadge method. 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. # -# Additional handlers associated with the mode can be added by using the push method. For example, if a mode +# 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) => .... -# Any such additional handlers are removed when the mode exits. -# -# New mode types are created by inheriting from Mode or one of its sub-classes. Some generic cub-classes are -# provided below: +# Any such handlers are removed when the mode is deactivated. # -# SingletonMode: ensures that at most one instance of the mode is active at any one time. -# ExitOnBlur: exits the mode if the an indicated element loses the focus. -# ExitOnEscapeMode: exits the mode on escape. -# StateMode: tracks the current Vimium state in @enabled and @passKeys. -# -# To install and existing mode, use: +# To activate a mode, use: # myMode = new MyMode() # -# To remove a mode, use: -# myMode.exit() # externally triggered. +# Or (usually better) just: +# new MyMode() +# It is usually not necessary to retain a reference to the mode object. +# +# To deactivate a mode, use: # @exit() # internally triggered (more common). +# myMode.exit() # externally triggered. # # For debug only; to be stripped out. count = 0 class Mode - # Static. - @modes: [] + @debug = true # Constants; readable shortcuts for event-handler return values. continueBubbling: true @@ -52,31 +48,62 @@ class Mode stopBubblingAndTrue: handlerStack.stopBubblingAndTrue stopBubblingAndFalse: handlerStack.stopBubblingAndFalse - # Default values. - name: "" - badge: "" - keydown: null # null will be ignored by handlerStack (so it's a safe default). - keypress: null - keyup: null - constructor: (options={}) -> - Mode.modes.unshift @ - extend @, options - @modeIsActive = true - @count = ++count - console.log @count, "create:", @name - + @options = options @handlers = [] @exitHandlers = [] + @modeIsActive = true + @badge = options.badge || "" + @name = options.name || "anonymous" + + @count = ++count + console.log @count, "create:", @name if Mode.debug @push - keydown: @keydown - keypress: @keypress - keyup: @keyup + keydown: options.keydown || null + keypress: options.keypress || null + keyup: options.keyup || null updateBadge: (badge) => @alwaysContinueBubbling => @chooseBadge badge + # Some modes are singletons: there may be at most one instance active at any one time. A mode is a + # singleton if options.singleton is truthy. The value of options.singleton should be the key which is + # required to be unique. See PostFindMode for an example. + # New instances deactivate existing instances as they themselves are activated. @registerSingleton options.singleton if options.singleton + + # If options.exitOnEscape is truthy, then the mode will exit when the escape key is pressed. The + # triggering keyboard event will be passed to the mode's @exit() method. + if options.exitOnEscape + # Note. This handler ends up above the mode's own key handlers on the handler stack, so it takes + # priority. + @push + "keydown": (event) => + return @continueBubbling unless KeyboardUtils.isEscape event + @exit event + DomUtils.suppressKeyupAfterEscape handlerStack + @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 + "blur": (event) => @alwaysContinueBubbling => @exit() if event.srcElement == options.exitOnBlur + + # 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. + if options.trackState + @enabled = false + @passKeys = "" + @push + "registerStateChange": ({enabled: enabled, passKeys: passKeys}) => + @alwaysContinueBubbling => + if enabled != @enabled or passKeys != @passKeys + @enabled = enabled + @passKeys = passKeys + @registerStateChange?() + Mode.updateBadge() if @badge + # End of Mode.constructor(). push: (handlers) -> @handlers.push handlerStack.push handlers @@ -86,10 +113,9 @@ class Mode exit: -> if @modeIsActive - console.log @count, "exit:", @name + console.log @count, "exit:", @name if Mode.debug handler() for handler in @exitHandlers handlerStack.remove handlerId for handlerId in @handlers - Mode.modes = Mode.modes.filter (mode) => mode != @ Mode.updateBadge() @modeIsActive = false @@ -111,97 +137,30 @@ class Mode handler: "setBadge" badge: badge.badge - # Temporarily install a mode to call a function. + # Temporarily install a mode to protect a function call, then exit the mode. For example, temporarily + # install an InsertModeBlocker. @runIn: (mode, func) -> mode = new mode() func() mode.exit() - # Some modes are singletons: there may be at most one instance active at any one time. A mode is a - # singleton if options.singleton is truthy. The value of options.singleton should be the key which is - # required to be unique. See PostFindMode for an example. - @singletons: {} - registerSingleton: (singleton) -> - singletons = Mode.singletons - singletons[singleton].exit() if singletons[singleton] - singletons[singleton] = @ - @onExit => - delete singletons[singleton] if singletons[singleton] == @ - -# A SingletonMode is a Mode of which there may be at most one instance (of @singleton) active at any one time. -# New instances cancel previously-active instances on startup. -class SingletonMode extends Mode - @instances: {} - - exit: -> - delete SingletonMode.instances[@singleton] if @singleton? - super() - - constructor: (@singleton, options={}) -> - if @singleton? - SingletonMode.kill @singleton - SingletonMode.instances[@singleton] = @ - super options - - # Static method. Return whether the indicated mode (singleton) is currently active or not. - @isActive: (singleton) -> - @instances[singleton]? - - # Static method. If there's a singleton instance active, then kill it. - @kill: (singleton) -> - SingletonMode.instances[singleton].exit() if SingletonMode.instances[singleton] + registerSingleton: do -> + singletons = {} # Static. + (key) -> + singletons[key].exit() if singletons[key] + singletons[key] = @ -# This mode exits when the user hits Esc. -class ExitOnEscapeMode extends SingletonMode - constructor: (singleton, options) -> - super singleton, options - - # NOTE. This handler ends up above the mode's own key handlers on the handler stack, so it takes priority. - @push - "keydown": (event) => - return @continueBubbling unless KeyboardUtils.isEscape event - @exit - source: ExitOnEscapeMode - event: event - DomUtils.suppressKeyupAfterEscape handlerStack - @suppressEvent - -# This mode exits when element (if defined) loses the focus. -class ExitOnBlur extends ExitOnEscapeMode - constructor: (element, singleton=null, options={}) -> - super singleton, options - - if element? - @push - "blur": (event) => @alwaysContinueBubbling => @exit() if event.srcElement == element - -# The state mode tracks the enabled state in @enabled and @passKeys. It calls @registerStateChange() whenever -# the state changes. The state is distributed by bubbling a "registerStateChange" event down the handler -# stack. -class StateMode extends Mode - constructor: (options) -> - @enabled = false - @passKeys = "" - super options - - @push - "registerStateChange": ({enabled: enabled, passKeys: passKeys}) => - @alwaysContinueBubbling => - if enabled != @enabled or passKeys != @passKeys - @enabled = enabled - @passKeys = passKeys - @registerStateChange() - - # Overridden by sub-classes. - registerStateChange: -> + @onExit => delete singletons[key] if singletons[key] == @ -# BadgeMode is a psuedo mode for triggering badge updates on focus changes and state updates. It sits at the +# 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 choices of all other modes. -new class BadgeMode extends StateMode +# badge choices of other modes. +# Note. We also create the the one-and-only instance, here. +new class BadgeMode extends Mode constructor: (options) -> super name: "badge" + trackState: true @push "focus": => @alwaysContinueBubbling => Mode.updateBadge() @@ -215,7 +174,3 @@ new class BadgeMode extends StateMode root = exports ? window root.Mode = Mode -root.SingletonMode = SingletonMode -root.ExitOnBlur = ExitOnBlur -root.StateMode = StateMode -root.ExitOnEscapeMode = ExitOnEscapeMode diff --git a/content_scripts/mode_find.coffee b/content_scripts/mode_find.coffee index 18cb7b71..f9766e3a 100644 --- a/content_scripts/mode_find.coffee +++ b/content_scripts/mode_find.coffee @@ -3,26 +3,24 @@ # When we use find mode, the selection/focus can end up in a focusable/editable element. In this situation, # PostFindMode handles two special cases: # 1. Be an InsertModeBlocker. This prevents keyboard events from dropping us unintentionaly into insert -# mode. Here, this is achieved by inheriting from InsertModeBlocker. +# mode. This is achieved by inheriting from InsertModeBlocker. # 2. If the very-next keystroke is Escape, then drop immediately into insert mode. # class PostFindMode extends InsertModeBlocker constructor: (findModeAnchorNode) -> - element = document.activeElement - super name: "post-find" singleton: PostFindMode + element = document.activeElement return @exit() unless element and findModeAnchorNode # Special cases only arise if the active element can take input. So, exit immediately if it cannot not. canTakeInput = DomUtils.isSelectable(element) and DomUtils.isDOMDescendant findModeAnchorNode, element canTakeInput ||= element.isContentEditable - canTakeInput ||= findModeAnchorNode?.parentElement?.isContentEditable + canTakeInput ||= findModeAnchorNode.parentElement?.isContentEditable return @exit() unless canTakeInput - self = @ @push keydown: (event) -> if element == document.activeElement and KeyboardUtils.isEscape event @@ -33,13 +31,18 @@ class PostFindMode extends InsertModeBlocker @remove() true - # Install various ways in which we can leave this mode. + # Various ways in which we can leave PostFindMode. @push - DOMActive: (event) => @alwaysContinueBubbling => @exit() - click: (event) => @alwaysContinueBubbling => @exit() focus: (event) => @alwaysContinueBubbling => @exit() blur: (event) => @alwaysContinueBubbling => @exit() keydown: (event) => @alwaysContinueBubbling => @exit() if document.activeElement != element + # If element is selectable, then it's already focused. If the user clicks on it, then there's no new + # focus event, so InsertModeTrigger doesn't fire and we don't drop automatically into insert mode. + click: (event) => + @alwaysContinueBubbling => + new InsertMode event.target if DomUtils.isDOMDescendant element, event.target + @exit() + root = exports ? window root.PostFindMode = PostFindMode diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee index 83d85fa7..b80a78ee 100644 --- a/content_scripts/mode_insert.coffee +++ b/content_scripts/mode_insert.coffee @@ -18,36 +18,35 @@ isFocusable =(element) -> isEditable(element) or isEmbed element # This mode is installed when insert mode is active. -class InsertMode extends ExitOnBlur - constructor: (@insertModeLock=null) -> - super @insertModeLock, InsertMode, +class InsertMode extends Mode + constructor: (@insertModeLock = null) -> + super name: "insert" badge: "I" keydown: (event) => @stopBubblingAndTrue keypress: (event) => @stopBubblingAndTrue keyup: (event) => @stopBubblingAndTrue + singleton: InsertMode + exitOnEscape: true + exitOnBlur: @insertModeLock - exit: (extra={}) -> + exit: (event = null) -> super() - if extra.source == ExitOnEscapeMode and extra.event?.srcElement? - if isFocusable extra.event.srcElement + if @insertModeLock and event?.srcElement == @insertModeLock + if isFocusable @insertModeLock # Remove the focus so the user can't just get himself back into insert mode by typing in the same # input box. # NOTE(smblott, 2014/12/22) Including embeds for .blur() 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. - extra.event.srcElement.blur() + @insertModeLock.blur() - # Static method. Return whether insert mode is currently active or not. - @isActive: (singleton) -> SingletonMode.isActive InsertMode + # Static method. Check whether insert mode is currently active. + @isActive: (extra) -> extra?.insertModeIsActive # Trigger insert mode: # - On a keydown event in a contentEditable element. # - When a focusable element receives the focus. -# - When an editable activeElement is clicked. We cannot rely exclusively on focus events for triggering -# insert mode. With find mode, an editable element can be active, but we're not in insert mode (see -# PostFindMode), so no focus event will be generated. In this case, clicking on the element should -# activate insert mode. # # This mode is permanently installed fairly low down on the handler stack. class InsertModeTrigger extends Mode @@ -55,7 +54,7 @@ class InsertModeTrigger extends Mode super name: "insert-trigger" keydown: (event, extra) => - return @continueBubbling if InsertModeBlocker.isActive extra + return @continueBubbling if InsertModeTrigger.isDisabled extra # Some sites (e.g. inbox.google.com) change the contentEditable attribute on the fly (see #1245); # and unfortunately, the focus event happens *before* the change is made. Therefore, we need to # check again whether the active element is contentEditable. @@ -66,31 +65,29 @@ class InsertModeTrigger extends Mode @push focus: (event, extra) => @alwaysContinueBubbling => - unless InsertMode.isActive() or InsertModeBlocker.isActive extra - new InsertMode event.target if isFocusable event.target + return @continueBubbling if InsertModeTrigger.isDisabled extra + return if not isFocusable event.target + new InsertMode event.target - click: (event, extra) => - @alwaysContinueBubbling => - # Do not check InsertModeBlocker.isActive() here. A user click overrides the blocker. - unless InsertMode.isActive() - if document.activeElement == event.target and isEditable event.target - new InsertMode event.target + # We may already have focussed an input, so check. + new InsertMode document.activeElement if document.activeElement and isEditable document.activeElement - # We may already have focussed something, so check. - new InsertMode document.activeElement if document.activeElement and isFocusable document.activeElement + # Allow other modes to disable this trigger. Static. + @disable: (extra) -> extra.disableInsertModeTrigger = true + @isDisabled: (extra) -> extra?.disableInsertModeTrigger -# Disables InsertModeTrigger. Used by find mode and findFocus to prevent unintentionally dropping into insert -# mode on focusable elements. +# Disables InsertModeTrigger. This is used by find mode and by findFocus to prevent unintentionally dropping +# into insert mode on focusable elements. class InsertModeBlocker extends Mode - constructor: (options={}) -> + constructor: (options = {}) -> options.name ||= "insert-blocker" super options @push - "all": (event, extra) => @alwaysContinueBubbling => extra.isInsertModeBlockerActive = true - - # Static method. Return whether an insert-mode blocker is currently active or not. - @isActive: (extra) -> extra?.isInsertModeBlockerActive + "focus": (event, extra) => @alwaysContinueBubbling -> InsertModeTrigger.disable extra + "keydown": (event, extra) => @alwaysContinueBubbling -> InsertModeTrigger.disable extra + "keypress": (event, extra) => @alwaysContinueBubbling -> InsertModeTrigger.disable extra + "keyup": (event, extra) => @alwaysContinueBubbling -> InsertModeTrigger.disable extra root = exports ? window root.InsertMode = InsertMode diff --git a/content_scripts/mode_passkeys.coffee b/content_scripts/mode_passkeys.coffee index c8afed39..972dcad7 100644 --- a/content_scripts/mode_passkeys.coffee +++ b/content_scripts/mode_passkeys.coffee @@ -1,7 +1,11 @@ -class PassKeysMode extends StateMode - configure: (request) -> - @keyQueue = request.keyQueue if request.keyQueue? +class PassKeysMode extends Mode + constructor: -> + super + name: "passkeys" + keydown: (event) => @handlePassKeyEvent event + keypress: (event) => @handlePassKeyEvent event + trackState: true # Decide whether this event 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 @@ -11,11 +15,8 @@ class PassKeysMode extends StateMode return @stopBubblingAndTrue if keyChar and not @keyQueue and 0 <= @passKeys.indexOf(keyChar) @continueBubbling - constructor: -> - super - name: "passkeys" - keydown: (event) => @handlePassKeyEvent event - keypress: (event) => @handlePassKeyEvent event + configure: (request) -> + @keyQueue = request.keyQueue if request.keyQueue? chooseBadge: (badge) -> @badge = if @passKeys and not @keyQueue then "P" else "" diff --git a/content_scripts/mode_visual.coffee b/content_scripts/mode_visual.coffee index a9acf8be..2580106d 100644 --- a/content_scripts/mode_visual.coffee +++ b/content_scripts/mode_visual.coffee @@ -1,10 +1,11 @@ -# Note. ExitOnBlur extends extends ExitOnEscapeMode. So exit-on-escape is handled there. -class VisualMode extends ExitOnBlur +class VisualMode extends Mode constructor: (element=null) -> - super element, null, + super name: "visual" badge: "V" + exitOnEscape: true + exitOnBlur: element keydown: (event) => return @suppressEvent diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 193a1592..f0196c74 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -469,7 +469,7 @@ onKeypress = (event, extra) -> keyPort.postMessage({ keyChar:keyChar, frameId:frameId }) - if InsertModeBlocker.isActive extra + if InsertModeTrigger.isDisabled extra # If PostFindMode is active, then we're blocking vimium's keystrokes from going into an input # element. So we should also block other keystrokes (otherwise, it's weird). There's some controversy as # to whether this is the right thing to do. See discussion in #1415. @@ -568,7 +568,7 @@ onKeydown = (event, extra) -> isValidFirstKey(KeyboardUtils.getKeyChar(event)))) DomUtils.suppressPropagation(event) KeydownEvents.push event - else if InsertModeBlocker.isActive extra + else if InsertModeTrigger.isDisabled extra # If PostFindMode is active, then we're blocking vimium's keystrokes from going into an input # element. So we should also block other keystrokes (otherwise, it's weird). There's some controversy as # to whether this is the right thing to do. See discussion in #1415. @@ -747,11 +747,12 @@ handleEnterForFindMode = -> document.body.classList.add("vimiumFindMode") settings.set("findModeRawQuery", findModeQuery.rawQuery) -class FindMode extends ExitOnEscapeMode +class FindMode extends Mode constructor: -> - super FindMode, + super name: "find" badge: "/" + exitOnEscape: true keydown: (event) => if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey @@ -773,9 +774,9 @@ class FindMode extends ExitOnEscapeMode keyup: (event) => @suppressEvent - exit: (extra) -> - handleEscapeForFindMode() if extra?.source == ExitOnEscapeMode + exit: (event) -> super() + handleEscapeForFindMode() if event and KeyboardUtils.isEscape event new PostFindMode findModeAnchorNode performFindInPlace = -> diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee index 9da0bc33..4d186341 100644 --- a/lib/handler_stack.coffee +++ b/lib/handler_stack.coffee @@ -15,11 +15,10 @@ class HandlerStack @stopBubblingAndFalse = new Object() # Adds a handler to the stack. Returns a unique ID for that handler that can be used to remove it later. - # We use unshift (which is more expensive than push) so that bubbleEvent can just iterate over the stack in - # the normal order. push: (handler) -> - @stack.unshift handler handler.id = ++@counter + @stack.push handler + handler.id # 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 @@ -27,8 +26,10 @@ class HandlerStack bubbleEvent: (type, event) -> # extra is passed to each handler. This allows handlers to pass information down the stack. extra = {} - for handler in @stack[..] # Take a copy of @stack, so that concurrent removes do not interfere. - # We need to check whether the handler has been removed (handler.id == null). + # We take a copy of the array, here, in order to avoid interference from concurrent removes (for example, + # to avoid calling the same handler twice). + for handler in @stack[..].reverse() + # A handler may have been removed (handler.id == null). if handler and handler.id @currentId = handler.id # A handler can register a handler for type "all", which will be invoked on all events. Such an "all" @@ -44,12 +45,12 @@ class HandlerStack true remove: (id = @currentId) -> - # This is more expense than splicing @stack, but better because splicing can interfere with concurrent - # bubbleEvents. - @stack = @stack.filter (handler) -> - # Mark this handler as removed (so concurrent bubbleEvents will know not to invoke it). - handler.id = null if handler.id == id - handler?.id? + for i in [(@stack.length - 1)..0] by -1 + handler = @stack[i] + if handler.id == id + handler.id = null + @stack.splice(i, 1) + break # The handler stack handles chrome events (which may need to be suppressed) and internal (fake) events. # This checks whether that the event at hand is a chrome event. |
