diff options
| author | Stephen Blott | 2015-01-04 09:29:36 +0000 |
|---|---|---|
| committer | Stephen Blott | 2015-01-04 13:00:50 +0000 |
| commit | 9ae4b6c10d53153929d905f28bc7de57c0ba6dfe (patch) | |
| tree | e6243e08f2f4e0925c3960dd68381d917d46a510 | |
| parent | 615f8a79f91f1d868465a6dae903c6710103515f (diff) | |
| download | vimium-9ae4b6c10d53153929d905f28bc7de57c0ba6dfe.tar.bz2 | |
Modes; various improvements.
- Add StateMode.
- PasskeysMode is a StateMode.
- BadgeUpdateMode is a StateMode.
- Improve badge handling.
- Add push method to Mode.
- Document how modes work.
- Cache badge on background page to reduce the number of updates.
- Remove badge restriction on document.body?.tagName.toLowerCase() == "frameset".
- Add ExitOnEscape mode, use it for ConstrainedMode and FindMode.
- Move PostFindMode to its own file.
| -rw-r--r-- | background_scripts/main.coffee | 9 | ||||
| -rw-r--r-- | content_scripts/mode.coffee | 155 | ||||
| -rw-r--r-- | content_scripts/mode_find.coffee | 59 | ||||
| -rw-r--r-- | content_scripts/mode_insert.coffee | 18 | ||||
| -rw-r--r-- | content_scripts/mode_passkeys.coffee | 17 | ||||
| -rw-r--r-- | content_scripts/mode_visual.coffee | 8 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 106 | ||||
| -rw-r--r-- | lib/dom_utils.coffee | 7 | ||||
| -rw-r--r-- | manifest.json | 1 |
9 files changed, 231 insertions, 149 deletions
diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index fc0a792f..008eb89f 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -342,8 +342,13 @@ setBrowserActionIcon = (tabId,path) -> # This color should match the blue of the Vimium browser popup (although it looks a little darker, to me?). chrome.browserAction.setBadgeBackgroundColor {color: [102, 176, 226, 255]} -setBadge = (request) -> - chrome.browserAction.setBadgeText {text: request.badge || ""} +setBadge = do -> + current = "" + (request) -> + badge = request.badge + if badge? and badge != current + chrome.browserAction.setBadgeText {text: badge || ""} + current = badge # Updates the browserAction icon to indicate whether Vimium is enabled or disabled on the current page. # Also propagates new enabled/disabled/passkeys state to active window, if necessary. diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee index 64001eaa..10b7bb2a 100644 --- a/content_scripts/mode.coffee +++ b/content_scripts/mode.coffee @@ -1,4 +1,43 @@ - +# Modes. +# +# A mode implements a number of event handlers which are pushed onto the handler stack when the mode starts, +# and poped when the mode exits. The Mode base takes as single argument options which can defined: +# +# 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 set a badge and override the +# chooseBadge method instead. Or, if the mode *never* shows a bade, 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 +# 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: +# SingletonMode: ensures that at most one instance of the mode should be active at any time. +# ConstrainedMode: exits the mode if the user clicks outside of the given element. +# ExitOnEscapeMode: exits the mode if the user types Esc. +# StateMode: tracks the current Vimium state in @enabled and @passKeys. +# +# To install and existing mode, use: +# myMode = new MyMode() +# +# To remove a mode, use: +# myMode.exit() # externally triggered. +# @exit() # internally triggered (more common). +# + +# Debug only; to be stripped out. count = 0 class Mode @@ -15,23 +54,26 @@ class Mode # Default values. name: "" badge: "" - keydown: (event) => @continueBubbling - keypress: (event) => @continueBubbling - keyup: (event) => @continueBubbling + keydown: null + keypress: null + keyup: null - constructor: (options) -> + constructor: (options={}) -> Mode.modes.unshift @ extend @, options @count = ++count console.log @count, "create:", @name @handlers = [] - @handlers.push handlerStack.push + @push keydown: @keydown keypress: @keypress keyup: @keyup updateBadge: (badge) => handlerStack.alwaysContinueBubbling => @chooseBadge badge + push: (handlers) -> + @handlers.push handlerStack.push handlers + exit: -> console.log @count, "exit:", @name handlerStack.remove handlerId for handlerId in @handlers @@ -45,14 +87,13 @@ class Mode badge.badge ||= @badge # 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 has the focus, and - # the document's body isn't a frameset. + # the resulting badge to the background page. We only update the badge if this document has the focus. @updateBadge: -> if document.hasFocus() - unless document.body?.tagName.toLowerCase() == "frameset" - badge = {badge: ""} - handlerStack.bubbleEvent "updateBadge", badge - chrome.runtime.sendMessage({ handler: "setBadge", badge: badge.badge }) + handlerStack.bubbleEvent "updateBadge", badge = {badge: ""} + chrome.runtime.sendMessage + handler: "setBadge" + badge: badge.badge # Temporarily install a mode. @runIn: (mode, func) -> @@ -60,10 +101,6 @@ class Mode func() mode.exit() -# We need to detect when the focused frame/tab changes, and update the badge. -handlerStack.push - "focus": -> handlerStack.alwaysContinueBubbling -> Mode.updateBadge() - # A SingletonMode is a Mode of which there may be at most one instance (of @singleton) active at any one time. # New instances cancel previous instances on startup. class SingletonMode extends Mode @@ -74,26 +111,35 @@ class SingletonMode extends Mode super() constructor: (@singleton, options={}) -> - SingletonMode.instances[@singleton].exit() if SingletonMode.instances[@singleton] + SingletonMode.kill @singleton SingletonMode.instances[@singleton] = @ super options -# # MultiMode is a collection of modes which are installed or uninstalled together. -# class MultiMode extends Mode -# constructor: (modes...) -> -# @modes = (new mode() for mode in modes) -# super {name: "multimode"} -# -# exit: -> -# mode.exit() for mode in modes + # Static method. If there's a singleton instance running, then kill it. + @kill: (singleton) -> + SingletonMode.instances[singleton].exit() if SingletonMode.instances[singleton] + +# The mode exits when the user hits Esc. +class ExitOnEscapeMode extends Mode + constructor: (options) -> + super options + + # 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 + @suppressEvent # When the user clicks anywhere outside of the given element, the mode is exited. -class ConstrainedMode extends Mode +class ConstrainedMode extends ExitOnEscapeMode constructor: (@element, options) -> options.name = if options.name? then "constrained-#{options.name}" else "constrained" super options - @handlers.push handlerStack.push + @push "click": (event) => @exit() unless @isDOMDescendant @element, event.srcElement @continueBubbling @@ -105,19 +151,52 @@ class ConstrainedMode extends Mode node = node.parentNode false -# # The mode exits when the user hits Esc. -# class ExitOnEscapeMode extends Mode -# constructor: (options) -> -# super options -# -# # This handler ends up above the mode's own handlers on the handler stack, so it takes priority. -# @handlers.push handlerStack.push -# "keydown": (event) => -# return @continueBubbling unless KeyboardUtils.isEscape event -# @exit() -# @suppressEvent +# The state mode tracks the enabled state in @enabled and @passKeys, and its initialized state in +# @initialized. It calls @registerStateChange() whenever the state changes. +class StateMode extends Mode + constructor: (options) -> + @stateInitialized = false + @enabled = false + @passKeys = "" + super options + + @push + "registerStateChange": ({enabled: enabled, passKeys: passKeys}) => + handlerStack.alwaysContinueBubbling => + if enabled != @enabled or passKeys != @passKeys or not @stateInitialized + @stateInitialized = true + @enabled = enabled + @passKeys = passKeys + @registerStateChange() + + # Overridden by sub-classes. + registerStateChange: -> + +# BadgeMode is a psuedo mode for managing 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. +class BadgeMode extends StateMode + constructor: (options) -> + options.name ||= "badge" + super options + + @push + "focus": => + handlerStack.alwaysContinueBubbling => + Mode.updateBadge() + + chooseBadge: (badge) -> + # If we're not enabled, then post an empty badge (so, no badge at all). + badge.badge = "" unless @enabled + + registerStateChange: -> + Mode.updateBadge() + +# Install a single BadgeMode instance. +new BadgeMode {} root = exports ? window root.Mode = Mode root.SingletonMode = SingletonMode root.ConstrainedMode = ConstrainedMode +root.StateMode = StateMode +root.ExitOnEscapeMode = ExitOnEscapeMode diff --git a/content_scripts/mode_find.coffee b/content_scripts/mode_find.coffee new file mode 100644 index 00000000..d6380682 --- /dev/null +++ b/content_scripts/mode_find.coffee @@ -0,0 +1,59 @@ +# 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. +# +# PostFindMode also maps Esc (on the next keystroke) to immediately drop into insert mode. +class PostFindMode extends SingletonMode + constructor: (insertMode, findModeAnchorNode) -> + element = document.activeElement + return unless element + + # 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" + + # If the very next key is Esc, then drop straight into insert mode. + do => + self = @ + @push + keydown: (event) -> + @remove() + if element == document.activeElement and KeyboardUtils.isEscape event + self.exit() + # NOTE(smblott). The legacy code (2015/1/4) uses DomUtils.simulateSelect() here. But this moves + # the selection. It's better to leave the selection where it is. + insertMode.activate element + return false + 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() + click: (event) => handlerStack.alwaysContinueBubbling => @exit() + focus: (event) => handlerStack.alwaysContinueBubbling => @exit() + 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: (element) -> + SingletonMode.kill PostFindMode + insertMode.activate element + +root = exports ? window +root.PostFindMode = PostFindMode diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee index cffb8735..5a0ac9eb 100644 --- a/content_scripts/mode_insert.coffee +++ b/content_scripts/mode_insert.coffee @@ -46,6 +46,18 @@ class InsertMode extends Mode @badge = "" Mode.updateBadge() + exit: (event) -> + if event?.source == ExitOnEscapeMode + element = event?.event?.srcElement + if element? and @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() + constructor: -> super name: "insert" @@ -65,7 +77,7 @@ class InsertMode extends Mode keypress: => if @insertModeActive then @stopBubblingAndTrue else @continueBubbling keyup: => if @insertModeActive then @stopBubblingAndTrue else @continueBubbling - @handlers.push handlerStack.push + @push focus: (event) => handlerStack.alwaysContinueBubbling => if not @insertModeActive and @isFocusable event.target @@ -86,8 +98,8 @@ class InsertMode extends Mode # Activate this mode to prevent a focused, editable element from triggering insert mode. class InsertModeSuppressFocusTrigger extends Mode constructor: -> - super {name: "suppress-focus-trigger"} - @handlers.push handlerStack.push + super {name: "suppress-insert-mode-focus-trigger"} + @push focus: => @suppressEvent root = exports ? window diff --git a/content_scripts/mode_passkeys.coffee b/content_scripts/mode_passkeys.coffee index 4c4d7d41..c8afed39 100644 --- a/content_scripts/mode_passkeys.coffee +++ b/content_scripts/mode_passkeys.coffee @@ -1,20 +1,7 @@ -class PassKeysMode extends Mode - keyQueue: "" - passKeys: "" - - # This is called to set the passKeys configuration and state with various types of request from various - # sources, so we handle several cases here. - # TODO(smblott) Rationalize this. +class PassKeysMode extends StateMode configure: (request) -> - if request.isEnabledForUrl? - @passKeys = (request.isEnabledForUrl and request.passKeys) or "" - Mode.updateBadge() - if request.enabled? - @passKeys = (request.enabled and request.passKeys) or "" - Mode.updateBadge() - if request.keyQueue? - @keyQueue = request.keyQueue + @keyQueue = request.keyQueue if request.keyQueue? # 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 diff --git a/content_scripts/mode_visual.coffee b/content_scripts/mode_visual.coffee index 07530e94..b07d784e 100644 --- a/content_scripts/mode_visual.coffee +++ b/content_scripts/mode_visual.coffee @@ -1,19 +1,13 @@ +# Note. ConstrainedMode extends extends ExitOnEscapeMode. So exit-on-escape is handled there. class VisualMode extends ConstrainedMode - # Proposal... The visual selection must stay within element. This will become relevant if we ever get so - # far as implementing a vim-like editing mode for text areas/content editable. - # constructor: (element=document.body) -> super element, name: "visual" badge: "V" keydown: (event) => - if KeyboardUtils.isEscape event - @exit() - return Mode.suppressEvent - return Mode.suppressEvent keypress: (event) => diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index d1ada884..9d539956 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -133,8 +133,6 @@ initializePreDomReady = -> Scroller.init settings # Install passKeys and insert modes. These too are permanently on the stack (although not always active). - # Note. There's no need to explicitly Mode.updateBadge(). The new InsertMode() updates the badge. - # Note. There's no need to explicitly Mode.updateBadge(). The new InsertMode() updates the badge. passKeysMode = new PassKeysMode() insertMode = new InsertMode() Mode.updateBadge() @@ -163,9 +161,7 @@ initializePreDomReady = -> getScrollPosition: -> scrollX: window.scrollX, scrollY: window.scrollY setScrollPosition: (request) -> setScrollPosition request.scrollX, request.scrollY executePageCommand: executePageCommand - getActiveState: -> - Mode.updateBadge() - return { enabled: isEnabledForUrl, passKeys: passKeys } + getActiveState: getActiveState setState: setState currentKeyQueue: (request) -> keyQueue = request.keyQueue @@ -210,7 +206,13 @@ setState = (request) -> initializeWhenEnabled(request.passKeys) if request.enabled isEnabledForUrl = request.enabled passKeys = request.passKeys - passKeysMode.configure request + 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. @@ -281,7 +283,6 @@ window.focusThisFrame = (shouldHighlight) -> chrome.runtime.sendMessage({ handler: "nextFrame", frameId: frameId }) return window.focus() - Mode.updateBadge() if (document.body && shouldHighlight) borderWas = document.body.style.border document.body.style.border = '5px solid yellow' @@ -359,13 +360,9 @@ extend window, selectedInputIndex = Math.min(count - 1, visibleInputs.length - 1) - # We need to make sure that the following .focus() actually does generate a "focus" event. We need such - # an event: - # - to trigger insert mode, and - # - to kick any PostFindMode listeners out of the way. - # Unfortunately, if the element is already focused (as may happen following a find), then no "focus" event - # is generated. So, here, we first generate a psuedo "focus" event. - PostFindMode.fakeFocus visibleInputs[selectedInputIndex].element + # See the definition of PostFindMode.exitModeAndEnterInsert for an explanation of why this is needed. + PostFindMode.exitModeAndEnterInsert visibleInputs[selectedInputIndex].element + visibleInputs[selectedInputIndex].element.focus() return if visibleInputs.length == 1 @@ -578,8 +575,9 @@ 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() - passKeysMode.configure response - Mode.updateBadge() + handlerStack.bubbleEvent "registerStateChange", + enabled: response.isEnabledForUrl + passKeys: response.passKeys refreshCompletionKeys = (response) -> if (response) @@ -733,20 +731,16 @@ handleEnterForFindMode = -> 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() + new PostFindMode insertMode, findModeAnchorNode -class FindMode extends Mode +class FindMode extends ExitOnEscapeMode constructor: (badge="F") -> super name: "find" badge: badge keydown: (event) => - if KeyboardUtils.isEscape event - handleEscapeForFindMode() - @exit() - @suppressEvent - else if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey + if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey handleDeleteForFindMode() @suppressEvent else if event.keyCode == keyCodes.enter @@ -767,56 +761,9 @@ class FindMode extends Mode Mode.updateBadge() -# Handle various special cases which arise when find finds a match within a focusable element. -class PostFindMode extends SingletonMode - constructor: -> - element = document.activeElement - - # Special cases only arise if the active element is focusable. So, exit immediately if it is not. - canTakeInput = element and DomUtils.isSelectable(element) and isDOMDescendant findModeAnchorNode, element - canTakeInput ||= element?.isContentEditable - return unless canTakeInput - - super PostFindMode, {name: "post-find-mode"} - - if element.isContentEditable - # Prevent InsertMode from activating on keydown. - @handlers.push handlerStack.push - keydown: (event) => - InsertMode.suppressKeydownTrigger event - @continueBubbling - - # If the next key is Esc, then drop into insert mode. - @handlers.push handlerStack.push - keydown: (event) -> - @remove() - return true unless KeyboardUtils.isEscape event - DomUtils.simulateSelect document.activeElement - insertMode.activate element - return false - - # We can stop watching on any change of focus or user click. - # FIXME(smblott). This is broken. If there is a text area, and the text area is focused with - # find, then clicking within that text area does *not* generate a useful event, and therefore does not - # disable PostFindMode mode, and therefore does not allow us to enter insert mode. - @handlers.push handlerStack.push - DOMActive: (event) => handlerStack.alwaysContinueBubbling => - console.log "ACTIVATE" - @exit() - click: (event) => - handlerStack.alwaysContinueBubbling => - console.log "CLICK" - @exit() - focus: (event) => handlerStack.alwaysContinueBubbling => - console.log "FOCUS" - @exit() - blur: (event) => - console.log "BLUR" - handlerStack.alwaysContinueBubbling => @exit() - - # This removes any PostFindMode modes on the stack and triggers a "focus" event for InsertMode. - @fakeFocus: (element) -> - handlerStack.bubbleEvent "focus", {target: element, note: "generated by PostFindMode.fakeFocus()"} + exit: (event) -> + handleEscapeForFindMode() if event?.source == ExitOnEscapeMode + super() performFindInPlace = -> cachedScrollX = window.scrollX @@ -863,13 +810,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. @@ -877,7 +817,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) @@ -914,7 +854,7 @@ findAndFocus = (backwards) -> # if we have found an input element via 'n', pressing <esc> immediately afterwards sends us into insert # mode - new PostFindMode() + new PostFindMode insertMode, findModeAnchorNode focusFoundLink() @@ -1033,9 +973,7 @@ window.enterFindMode = -> findModeQuery = { rawQuery: "" } # window.findMode = true # Same hack, see comment at window.findMode definition. HUD.show("/") - console.log "aaa" new FindMode() - console.log "bbb" exitFindMode = -> window.findMode = false # Same hack, see comment at window.findMode definition. diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index ba5e279f..ec846e44 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -141,6 +141,13 @@ DomUtils = (element.nodeName.toLowerCase() == "input" && unselectableTypes.indexOf(element.type) == -1) || element.nodeName.toLowerCase() == "textarea" + isDOMDescendant: (parent, child) -> + node = child + while (node != null) + return true if (node == parent) + node = node.parentNode + false + simulateSelect: (element) -> element.focus() # When focusing a textbox, put the selection caret at the end of the textbox's contents. diff --git a/manifest.json b/manifest.json index 2d01ad50..a04d8c0e 100644 --- a/manifest.json +++ b/manifest.json @@ -46,6 +46,7 @@ "content_scripts/mode.js", "content_scripts/mode_insert.js", "content_scripts/mode_passkeys.js", + "content_scripts/mode_find.js", "content_scripts/mode_visual.js", "content_scripts/vimium_frontend.js" ], |
