diff options
| author | Stephen Blott | 2015-01-04 16:19:14 +0000 |
|---|---|---|
| committer | Stephen Blott | 2015-01-05 12:16:13 +0000 |
| commit | 73f66f25e6b8e5b5b8456074ad4fa79ba1d3ca4d (patch) | |
| tree | 3cc98670418a886793f7c243e62026149dfbe0b9 | |
| parent | 45b2674e461659327f8e41ba10035abddde29b26 (diff) | |
| download | vimium-73f66f25e6b8e5b5b8456074ad4fa79ba1d3ca4d.tar.bz2 | |
Modes; revise InsertMode as two classes.
| -rw-r--r-- | content_scripts/mode.coffee | 47 | ||||
| -rw-r--r-- | content_scripts/mode_find.coffee | 48 | ||||
| -rw-r--r-- | content_scripts/mode_insert.coffee | 171 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 35 | ||||
| -rw-r--r-- | lib/handler_stack.coffee | 17 |
5 files changed, 149 insertions, 169 deletions
diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee index 76b65a12..96fc9b0c 100644 --- a/content_scripts/mode.coffee +++ b/content_scripts/mode.coffee @@ -61,6 +61,7 @@ class Mode constructor: (options={}) -> Mode.modes.unshift @ extend @, options + @modeIsActive = true @count = ++count console.log @count, "create:", @name @@ -75,12 +76,14 @@ class Mode @handlers.push handlerStack.push handlers exit: -> - console.log @count, "exit:", @name - # We reverse @handlers, here. That way, handlers are popped in the opposite order to that in which they - # were pushed. - handlerStack.remove handlerId for handlerId in @handlers.reverse() - Mode.modes = Mode.modes.filter (mode) => mode != @ - Mode.updateBadge() + if @modeIsActive + console.log @count, "exit:", @name + # We reverse @handlers, here. That way, handlers are popped in the opposite order to that in which they + # were pushed. + handlerStack.remove handlerId for handlerId in @handlers.reverse() + 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. chooseBadge, here, is the default: choose the current mode's badge unless @@ -122,9 +125,9 @@ class SingletonMode extends Mode SingletonMode.instances[singleton].exit() if SingletonMode.instances[singleton] # The mode exits when the user hits Esc. -class ExitOnEscapeMode extends Mode - constructor: (options) -> - super options +class ExitOnEscapeMode extends SingletonMode + constructor: (singleton, options) -> + super singleton, options # This handler ends up above the mode's own key handlers on the handler stack, so it takes priority. @push @@ -135,23 +138,17 @@ class ExitOnEscapeMode extends Mode event: event @suppressEvent -# When the user clicks anywhere outside of the given element, the mode is exited. +# When @element loses the focus. class ConstrainedMode extends ExitOnEscapeMode - constructor: (@element, options) -> - options.name = if options.name? then "constrained-#{options.name}" else "constrained" - super options - - @push - "click": (event) => - @exit() unless @isDOMDescendant @element, event.srcElement - @continueBubbling - - isDOMDescendant: (parent, child) -> - node = child - while (node != null) - return true if (node == parent) - node = node.parentNode - false + constructor: (@element, singleton, options) -> + super singleton, options + + if @element + @element.focus() + @push + "blur": (event) => + handlerStack.alwaysContinueBubbling => + @exit() if event.srcElement == @element # The state mode tracks the enabled state in @enabled and @passKeys, and its initialized state in # @initialized. It calls @registerStateChange() whenever the state changes. diff --git a/content_scripts/mode_find.coffee b/content_scripts/mode_find.coffee index d6d1ff33..795e7a14 100644 --- a/content_scripts/mode_find.coffee +++ b/content_scripts/mode_find.coffee @@ -1,37 +1,35 @@ # NOTE(smblott). Ultimately, all of the FindMode-related code should be moved to this file. -# When we use find mode, the selection/focus can end up in a focusable/editable element. Subsequent keyboard -# events could drop us into insert mode, which is a bad user experience. The PostFindMode mode is installed -# after find events to prevent this. +# 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. Suppress InsertModeTrigger. This presents keyboard events from dropping us unintentionaly into insert +# mode. Here, this is achieved by inheriting PostFindMode from InsertModeBlocker. +# 2. If the very-next keystroke is Escape, then drop immediately into insert mode. # -# PostFindMode also maps Esc (on the next keystroke) to immediately drop into insert mode. -class PostFindMode extends SingletonMode - constructor: (insertMode, findModeAnchorNode) -> +class PostFindMode extends InsertModeBlocker + constructor: (findModeAnchorNode) -> element = document.activeElement - return unless element + + super PostFindMode, element, + name: "post-find" + + return @exit() unless element and findModeAnchorNode # Special cases only arise if the active element is focusable. So, exit immediately if it is not. canTakeInput = DomUtils.isSelectable(element) and DomUtils.isDOMDescendant findModeAnchorNode, element - canTakeInput ||= element?.isContentEditable - return unless canTakeInput - - super PostFindMode, - name: "post-find" + canTakeInput ||= element.isContentEditable + return @exit() unless canTakeInput - # If the very next key is Esc, then drop straight into insert mode. + self = @ @push keydown: (event) -> - @remove() if element == document.activeElement and KeyboardUtils.isEscape event - PostFindMode.exitModeAndEnterInsert insertMode, element + self.exit() + new InsertMode element return false + @remove() true - if element.isContentEditable - # Prevent InsertMode from activating on keydown. - @push - keydown: (event) -> handlerStack.alwaysContinueBubbling -> InsertMode.suppressKeydownTrigger event - # Install various ways in which we can leave this mode. @push DOMActive: (event) => handlerStack.alwaysContinueBubbling => @exit() @@ -40,15 +38,5 @@ class PostFindMode extends SingletonMode blur: (event) => handlerStack.alwaysContinueBubbling => @exit() keydown: (event) => handlerStack.alwaysContinueBubbling => @exit() if document.activeElement != element - # There's feature interference between PostFindMode, InsertMode and focusInput. PostFindMode prevents - # InsertMode from triggering on keyboard events. And FindMode prevents InsertMode from triggering on focus - # events. This means that an input element can already be focused, but InsertMode is not active. When that - # element is then (again) focused by focusInput, no new focus event is generated, so we don't drop into - # InsertMode as expected. - # This hack fixes this. - @exitModeAndEnterInsert: (insertMode, element) -> - SingletonMode.kill PostFindMode - insertMode.activate insertMode, element - root = exports ? window root.PostFindMode = PostFindMode diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee index 5a0ac9eb..32994aef 100644 --- a/content_scripts/mode_insert.coffee +++ b/content_scripts/mode_insert.coffee @@ -1,107 +1,102 @@ -class InsertMode extends Mode - insertModeActive: false - insertModeLock: null - - # Input or text elements are considered focusable and able to receieve their own keyboard events, and will - # enter insert mode if focused. Also note that the "contentEditable" attribute can be set on any element - # which makes it a rich text editor, like the notes on jjot.com. - isEditable: (element) -> - return true if element.isContentEditable - nodeName = element.nodeName?.toLowerCase() - # Use a blacklist instead of a whitelist because new form controls are still being implemented for html5. - if nodeName == "input" and element.type not in ["radio", "checkbox"] - return true - nodeName in ["textarea", "select"] - - # Embedded elements like Flash and quicktime players can obtain focus. - isEmbed: (element) -> - element.nodeName?.toLowerCase() in ["embed", "object"] - - isFocusable: (element) -> - @isEditable(element) or @isEmbed element - - # Check whether insert mode is active. Also, activate insert mode if the current element is content - # editable (and the event is not suppressed). - isActiveOrActivate: (event) -> - return true if @insertModeActive - return false if event.suppressKeydownTrigger - # 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. - @activate() if document.activeElement?.isContentEditable - @insertModeActive - - activate: (target=null) -> - unless @insertModeActive - @insertModeActive = true - @insertModeLock = target - @badge = "I" - Mode.updateBadge() - - deactivate: -> - if @insertModeActive - @insertModeActive = false - @insertModeLock = null - @badge = "" - Mode.updateBadge() - - exit: (event) -> - if event?.source == ExitOnEscapeMode - element = event?.event?.srcElement - if element? and @isFocusable element +# Input or text elements are considered focusable and able to receieve their own keyboard events, and will +# enter insert mode if focused. Also note that the "contentEditable" attribute can be set on any element +# which makes it a rich text editor, like the notes on jjot.com. +isEditable =(element) -> + return true if element.isContentEditable + nodeName = element.nodeName?.toLowerCase() + # Use a blacklist instead of a whitelist because new form controls are still being implemented for html5. + if nodeName == "input" and element.type not in ["radio", "checkbox"] + return true + nodeName in ["textarea", "select"] + +# Embedded elements like Flash and quicktime players can obtain focus. +isEmbed =(element) -> + element.nodeName?.toLowerCase() in ["embed", "object"] + +isFocusable =(element) -> + isEditable(element) or isEmbed element + +class InsertMode extends ConstrainedMode + + constructor: (@insertModeLock=null) -> + super @insertModeLock, InsertMode, + name: "insert" + badge: "I" + keydown: (event) => @stopBubblingAndTrue + keypress: (event) => @stopBubblingAndTrue + keyup: (event) => @stopBubblingAndTrue + + @push + focus: (event, extra) => + handlerStack.alwaysContinueBubbling => + # Inform InsertModeTrigger that InsertMode is already active. + extra.insertModeActive = true + + Mode.updateBadge() + + exit: (event=null) -> + if event?.source == ExitOnEscapeMode and event?.event?.srcElement? + element = event.event.srcElement + if isFocusable element # 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. element.blur() - @deactivate() + super() +# Trigger insert mode: +# - On keydown event in a contentEditable element. +# - When a focusable element receives the focus. +# Can be suppressed by setting extra.suppressInsertModeTrigger. +class InsertModeTrigger extends Mode constructor: -> super - name: "insert" - keydown: (event) => - return @continueBubbling unless @isActiveOrActivate event - return @stopBubblingAndTrue unless KeyboardUtils.isEscape event - # We're in insert mode, and now exiting. - if event.srcElement? and @isFocusable event.srcElement - # 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. - event.srcElement.blur() - @deactivate() - @suppressEvent - keypress: => if @insertModeActive then @stopBubblingAndTrue else @continueBubbling - keyup: => if @insertModeActive then @stopBubblingAndTrue else @continueBubbling + name: "insert-trigger" + keydown: (event, extra) => + handlerStack.alwaysContinueBubbling => + unless extra.suppressInsertModeTrigger? + # 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 check + # whether the active element is contentEditable. + new InsertMode() if document.activeElement?.isContentEditable @push - focus: (event) => - handlerStack.alwaysContinueBubbling => - if not @insertModeActive and @isFocusable event.target - @activate event.target - blur: (event) => + focus: (event, extra) => handlerStack.alwaysContinueBubbling => - if @insertModeActive and event.target == @insertModeLock - @deactivate() + unless extra.suppressInsertModeTrigger? + new InsertMode event.target if isFocusable event.target - # We may already have focussed something, so check, so check. - @activate document.activeElement if document.activeElement and @isFocusable document.activeElement + # We may already have focussed something, so check. + new InsertMode document.activeElement if document.activeElement and isFocusable document.activeElement - # Used to prevent keydown events from triggering insert mode (following find). - # FIXME(smblott) This is a hack. - @suppressKeydownTrigger: (event) -> - event.suppressKeydownTrigger = true + @suppress: (extra) -> + extra.suppressInsertModeTrigger = true -# Activate this mode to prevent a focused, editable element from triggering insert mode. -class InsertModeSuppressFocusTrigger extends Mode - constructor: -> - super {name: "suppress-insert-mode-focus-trigger"} - @push - focus: => @suppressEvent +# Disables InsertModeTrigger. Used by find mode to prevent unintentionally dropping into insert mode on +# focusable elements. +# If @element is provided, then don't block focus events, and block keydown events only on the indicated +# element. +class InsertModeBlocker extends SingletonMode + constructor: (singleton=InsertModeBlocker, @element=null, options={}) -> + options.name ||= "insert-blocker" + super singleton, options + + unless @element? + @push + focus: (event, extra) => + handlerStack.alwaysContinueBubbling => + InsertModeTrigger.suppress extra + + if @element?.isContentEditable + @push + keydown: (event, extra) => + handlerStack.alwaysContinueBubbling => + InsertModeTrigger.suppress extra if event.srcElement == @element root = exports ? window root.InsertMode = InsertMode -root.InsertModeSuppressFocusTrigger = InsertModeSuppressFocusTrigger +root.InsertModeTrigger = InsertModeTrigger +root.InsertModeBlocker = InsertModeBlocker diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 75b4172f..299cdcf2 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -134,7 +134,7 @@ initializePreDomReady = -> # Install passKeys and insert modes. These too are permanently on the stack (although not always active). passKeysMode = new PassKeysMode() - insertMode = new InsertMode() + new InsertModeTrigger() Mode.updateBadge() checkIfEnabledForUrl() @@ -339,12 +339,14 @@ extend window, HUD.showForDuration("Yanked URL", 1000) enterInsertMode: -> - insertMode?.activate() + new InsertMode() enterVisualMode: => new VisualMode() focusInput: (count) -> + SingletonMode.kill PostFindMode + # 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. # Pressing any other key will remove the overlays and the special tab behavior. @@ -360,10 +362,14 @@ extend window, selectedInputIndex = Math.min(count - 1, visibleInputs.length - 1) - # See the definition of PostFindMode.exitModeAndEnterInsert for an explanation of why this is needed. - PostFindMode.exitModeAndEnterInsert insertMode, visibleInputs[selectedInputIndex].element - - visibleInputs[selectedInputIndex].element.focus() + # There's feature interference between PostFindMode, InsertMode and focusInput. PostFindMode prevents + # InsertMode from triggering on focus events. Therefore, an input element can already be focused, but + # InsertMode is not active. When that element is then (again) focused by focusInput, below, no new focus + # event is generated, so we don't drop into InsertMode as expected. + # Therefore we blur() the element before focussing it. + element = visibleInputs[selectedInputIndex].element + element.blur() if document.activeElement == element + element.focus() return if visibleInputs.length == 1 @@ -730,14 +736,12 @@ handleEnterForFindMode = -> focusFoundLink() document.body.classList.add("vimiumFindMode") settings.set("findModeRawQuery", findModeQuery.rawQuery) - # If we have found an input element, the pressing <esc> immediately afterwards sends us into insert mode. - new PostFindMode insertMode, findModeAnchorNode class FindMode extends ExitOnEscapeMode - constructor: (badge="F") -> - super + constructor: -> + super FindMode, name: "find" - badge: badge + badge: "/" keydown: (event) => if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey @@ -761,9 +765,10 @@ class FindMode extends ExitOnEscapeMode Mode.updateBadge() - exit: (event) -> - handleEscapeForFindMode() if event?.source == ExitOnEscapeMode + exit: (extra) -> + handleEscapeForFindMode() if extra?.source == ExitOnEscapeMode super() + new PostFindMode findModeAnchorNode performFindInPlace = -> cachedScrollX = window.scrollX @@ -792,7 +797,7 @@ executeFind = (query, options) -> HUD.hide(true) # ignore the selectionchange event generated by find() document.removeEventListener("selectionchange",restoreDefaultSelectionHighlight, true) - Mode.runIn InsertModeSuppressFocusTrigger, -> + Mode.runIn InsertModeBlocker, -> result = window.find(query, options.caseSensitive, options.backwards, true, false, true, false) setTimeout( -> document.addEventListener("selectionchange", restoreDefaultSelectionHighlight, true) @@ -854,7 +859,7 @@ findAndFocus = (backwards) -> # if we have found an input element via 'n', pressing <esc> immediately afterwards sends us into insert # mode - new PostFindMode insertMode, findModeAnchorNode + new PostFindMode findModeAnchorNode focusFoundLink() diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee index 17e4844b..0a34087f 100644 --- a/lib/handler_stack.coffee +++ b/lib/handler_stack.coffee @@ -40,17 +40,12 @@ class HandlerStack true remove: (id = @currentId) -> - if 0 < @stack.length and @stack[@stack.length-1].id == id - # A common case is to remove the handler at the top of the stack. And we can do this very efficiently. - # Tests suggest that this case arises more than half of the time. - @stack.pop().id = null - else - # Otherwise, we'll build a new stack. This is better than splicing the existing stack since that can - # interfere with concurrent bubbleEvents. - @stack = @stack.filter (handler) -> - # Mark this handler as removed (for any active bubbleEvent call). - handler.id = null if handler.id == id - handler?.id? + # 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 (to notify any concurrent bubbleEvent call). + if handler.id == id then handler.id = null + handler?.id? # 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. |
