diff options
| author | Stephen Blott | 2015-02-09 11:40:53 +0000 |
|---|---|---|
| committer | Stephen Blott | 2015-02-09 11:40:53 +0000 |
| commit | 0bf605a934115083e700f5de090f39841417482a (patch) | |
| tree | 408c191bd702da6847bca9943f6eba93ad28b209 | |
| parent | ac648a0e9f53c2fc359daa68309c25dd8c9db031 (diff) | |
| parent | ed306994697f6f9f5e13f9d018b5c7ffa2fff680 (diff) | |
| download | vimium-0bf605a934115083e700f5de090f39841417482a.tar.bz2 | |
Merge branch 'visual-and-edit-modes'
Conflicts:
background_scripts/main.coffee
content_scripts/vimium_frontend.coffee
lib/keyboard_utils.coffee
| -rw-r--r-- | background_scripts/commands.coffee | 6 | ||||
| -rw-r--r-- | background_scripts/main.coffee | 5 | ||||
| -rw-r--r-- | content_scripts/mode.coffee | 19 | ||||
| -rw-r--r-- | content_scripts/mode_find.coffee | 3 | ||||
| -rw-r--r-- | content_scripts/mode_insert.coffee | 11 | ||||
| -rw-r--r-- | content_scripts/mode_visual.coffee | 20 | ||||
| -rw-r--r-- | content_scripts/mode_visual_edit.coffee | 803 | ||||
| -rw-r--r-- | content_scripts/scroller.coffee | 55 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 48 | ||||
| -rw-r--r-- | lib/dom_utils.coffee | 64 | ||||
| -rw-r--r-- | lib/handler_stack.coffee | 15 | ||||
| -rw-r--r-- | lib/keyboard_utils.coffee | 9 | ||||
| -rw-r--r-- | lib/utils.coffee | 17 | ||||
| -rw-r--r-- | manifest.json | 2 |
14 files changed, 1024 insertions, 53 deletions
diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index 114f46ce..79cb9ee0 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -112,6 +112,8 @@ Commands = "goToRoot", "enterInsertMode", "enterVisualMode", + "enterVisualLineMode", + # "enterEditMode", "focusInput", "LinkHints.activateMode", "LinkHints.activateModeToOpenInNewTab", @@ -197,6 +199,8 @@ defaultKeyMappings = "i": "enterInsertMode" "v": "enterVisualMode" + "V": "enterVisualLineMode" + # "gv": "enterEditMode" "H": "goBack" "L": "goForward" @@ -286,6 +290,8 @@ commandDescriptions = enterInsertMode: ["Enter insert mode", { noRepeat: true }] enterVisualMode: ["Enter visual mode (not yet implemented)", { noRepeat: true }] + enterVisualLineMode: ["Enter visual line mode (not yet implemented)", { noRepeat: true }] + # enterEditMode: ["Enter vim-like edit mode (not yet implemented)", { noRepeat: true }] focusInput: ["Focus the first text box on the page. Cycle between them using tab", { passCountToFunction: true }] diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index 6853c3af..5a126ceb 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -168,9 +168,11 @@ upgradeNotificationClosed = (request) -> sendRequestToAllTabs({ name: "hideUpgradeNotification" }) # -# Copies some data (request.data) to the clipboard. +# Copies or pastes some data (request.data) to/from the clipboard. +# We return null to avoid the return value from the copy operations being passed to sendResponse. # copyToClipboard = (request) -> Clipboard.copy(request.data); null +pasteFromClipboard = (request) -> Clipboard.paste(); null # # Selects the tab with the ID specified in request.id @@ -647,6 +649,7 @@ sendRequestHandlers = upgradeNotificationClosed: upgradeNotificationClosed updateScrollPosition: handleUpdateScrollPosition copyToClipboard: copyToClipboard + pasteFromClipboard: pasteFromClipboard isEnabledForUrl: isEnabledForUrl saveHelpDialogSettings: saveHelpDialogSettings selectSpecificTab: selectSpecificTab diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee index 42ea9930..cc358bc2 100644 --- a/content_scripts/mode.coffee +++ b/content_scripts/mode.coffee @@ -78,7 +78,7 @@ class Mode if @options.exitOnBlur @push _name: "mode-#{@id}/exitOnBlur" - "blur": (event) => @alwaysContinueBubbling => @exit() if event.target == @options.exitOnBlur + "blur": (event) => @alwaysContinueBubbling => @exit event if event.target == @options.exitOnBlur # If @options.exitOnClick is truthy, then the mode will exit on any click event. if @options.exitOnClick @@ -92,11 +92,9 @@ class Mode 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() + key = Utils.getIdentity @options.singleton + @onExit -> delete singletons[key] + @deactivateSingleton @options.singleton singletons[key] = @ # If @options.trackState is truthy, then the mode mainatins the current state in @enabled and @passKeys, @@ -140,6 +138,9 @@ class Mode Mode.updateBadge() @modeIsActive = false + deactivateSingleton: (singleton) -> + Mode.singletons?[Utils.getIdentity singleton]?.exit() + # 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) -> @@ -150,6 +151,12 @@ class Mode # case), because they do not need to be concerned with the value they yield. alwaysContinueBubbling: handlerStack.alwaysContinueBubbling + # Activate a new instance of this mode, together with all of its original options (except its main + # keybaord-event handlers; these will be recreated). + cloneMode: -> + delete @options[key] for key in [ "keydown", "keypress", "keyup" ] + new @constructor @options + # 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. diff --git a/content_scripts/mode_find.coffee b/content_scripts/mode_find.coffee index dff63949..67f2a7dc 100644 --- a/content_scripts/mode_find.coffee +++ b/content_scripts/mode_find.coffee @@ -35,7 +35,8 @@ class PostFindMode extends SuppressPrintable name: "post-find" # We show a "?" badge, but only while an Escape activates insert mode. badge: "?" - singleton: PostFindMode + # PostFindMode shares a singleton with the modes launched by focusInput; each displaces the other. + singleton: element exitOnBlur: element exitOnClick: true keydown: (event) -> InsertMode.suppressEvent event # Always truthy, so always continues bubbling. diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee index 6932f419..90162d5a 100644 --- a/content_scripts/mode_insert.coffee +++ b/content_scripts/mode_insert.coffee @@ -31,19 +31,24 @@ class InsertMode extends Mode super extend defaults, options @insertModeLock = - if document.activeElement and DomUtils.isEditable document.activeElement + if options.targetElement and DomUtils.isEditable options.targetElement + # The caller has told us which element to activate on. + options.targetElement + else if document.activeElement and DomUtils.isEditable document.activeElement # An input element is already active, so use it. document.activeElement else null @push + _name: "mode-#{@id}-focus" "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 + # We don't exit if we're running under edit mode. Edit mode itself will handles that case. + @exit event, target if @insertModeLock and target == @insertModeLock and not @options.parentMode "focus": (event) => @alwaysContinueBubbling => if @insertModeLock != event.target and DomUtils.isFocusable event.target @activateOnElement event.target @@ -66,7 +71,6 @@ class InsertMode extends Mode 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 @@ -74,6 +78,7 @@ class InsertMode extends Mode if @permanent then Mode.updateBadge() else super() updateBadge: (badge) -> + badge.badge ||= @badge if @badge badge.badge ||= "I" if @isActive badge # Static stuff. This allows PostFindMode to suppress the permanently-installed InsertMode instance. diff --git a/content_scripts/mode_visual.coffee b/content_scripts/mode_visual.coffee deleted file mode 100644 index 2580106d..00000000 --- a/content_scripts/mode_visual.coffee +++ /dev/null @@ -1,20 +0,0 @@ - -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/mode_visual_edit.coffee b/content_scripts/mode_visual_edit.coffee new file mode 100644 index 00000000..e11c29ec --- /dev/null +++ b/content_scripts/mode_visual_edit.coffee @@ -0,0 +1,803 @@ + +# +# The main modes defined here are: +# - VisualMode +# - VisualLineMode +# - CaretMode +# - EditMode (experimental) +# +# SuppressPrintable and CountPrefix are shared utility base classes. +# Movement is a shared vim-like movement base class. +# +# The class inheritance hierarchy is: +# - Mode, SuppressPrintable, CountPrefix, Movement, [ VisualMode | CaretMode | EditMode ] +# - Mode, SuppressPrintable, CountPrefix, Movement, VisualMode, VisualLineMode +# +# The possible mode states are: +# - ..., VisualMode +# - ..., VisualLineMode +# - ..., CaretMode +# - ..., VisualMode, FindMode +# - ..., VisualLineMode, FindMode +# - ..., CaretMode, FindMode +# - ..., EditMode +# - ..., EditMode, InsertMode +# - ..., EditMode, VisualMode +# - ..., EditMode, VisualLineMode +# + +# This prevents printable characters from being passed through to underlying modes or the underlying page. +class SuppressPrintable extends Mode + constructor: (options = {}) -> + handler = (event) => + return @stopBubblingAndTrue if not KeyboardUtils.isPrintable event + return @suppressEvent if event.type != "keydown" + # Completely suppress Backspace and Delete, they change the selection. + return @suppressEvent if event.keyCode in [ keyCodes.backspace, keyCodes.deleteKey ] + # Suppress propagation (but not preventDefault) for keydown, printable events. + DomUtils.suppressPropagation event + @stopBubblingAndFalse + + super extend options, keydown: handler, keypress: handler, keyup: handler + +# This monitors keypresses and maintains the count prefix. +class CountPrefix extends SuppressPrintable + constructor: (options) -> + @countPrefix = "" + # This is an initial multiplier for the first count. It allows edit mode to implement both "d3w" and + # "3dw". Also, "3d2w" deletes six words. + @countPrefixFactor = options.initialCountPrefix || 1 + super options + + @push + _name: "#{@id}/count-prefix" + keypress: (event) => + @alwaysContinueBubbling => + unless event.metaKey or event.ctrlKey or event.altKey + keyChar = String.fromCharCode event.charCode + @countPrefix = + if keyChar.length == 1 and "0" <= keyChar <= "9" and @countPrefix + keyChar != "0" + @countPrefix + keyChar + else + "" + + getCountPrefix: -> + count = @countPrefixFactor * (if 0 < @countPrefix.length then parseInt @countPrefix else 1) + @countPrefix = ""; @countPrefixFactor = 1 + count + +# Symbolic names for some common strings. +forward = "forward" +backward = "backward" +character = "character" +word = "word" +line = "line" +sentence = "sentence" +paragraph = "paragraph" +vimword = "vimword" +lineboundary= "lineboundary" + +# This implements vim-like movements, and includes quite a number of gereral utility methods. +class Movement extends CountPrefix + opposite: forward: backward, backward: forward + + # Paste from clipboard. + paste: (callback) -> + chrome.runtime.sendMessage handler: "pasteFromClipboard", (response) -> callback response + + # Copy to clipboard. + copy: (text, isFinalUserCopy = false) -> + chrome.runtime.sendMessage handler: "copyToClipboard", data: text + # If isFinalUserCopy is set, then we're copying the final text selected by the user (and exiting). + # However, @protectClipboard may later try to restore the original clipboard contents. Therefore, we + # disable copy so that subsequent copies do not propagate. + @copy = (->) if isFinalUserCopy + + # This s used whenever manipulating the selection may, as a side effect, change the clipboard's contents. + # We restore the original clipboard contents when we're done. May be asynchronous. We use a lock so that + # calls can be nested. We do this primarily for edit mode, where the user does not expect caret movements + # to change the clipboard contents. + protectClipboard: do -> + locked = false + + (func) -> + if locked then func() + else + locked = true + @paste (text) => + func(); @copy text; locked = false + + # Replace the current mode with another. For example, replace caret mode with visual mode, or replace visual + # mode with visual-line mode. + changeMode: (mode, options = {}) -> + @exit() + if @options.parentMode + @options.parentMode.launchSubMode mode, options + else + new mode options + + # Return the character following (to the right of) the focus, and leave the selection unchanged. Returns + # undefined if no such character exists. + getNextForwardCharacter: -> + beforeText = @selection.toString() + if beforeText.length == 0 or @getDirection() == forward + @selection.modify "extend", forward, character + afterText = @selection.toString() + if beforeText != afterText + @selection.modify "extend", backward, character + afterText[afterText.length - 1] + else + beforeText[0] # Existing range selection is backwards. + + # As above, but backwards. + getNextBackwardCharacter: -> + beforeText = @selection.toString() + if beforeText.length == 0 or @getDirection() == backward + @selection.modify "extend", backward, character + afterText = @selection.toString() + if beforeText != afterText + @selection.modify "extend", forward, character + afterText[0] + else + beforeText[beforeText.length - 1] # Existing range selection is forwards. + + # Test whether the character following the focus is a word character (and leave the selection unchanged). + nextCharacterIsWordCharacter: do -> + regexp = /[A-Za-z0-9_]/; -> regexp.test @getNextForwardCharacter() + + # Run a movement. This is the core movement method, all movements happen here. For convenience, the + # following three argument forms are supported: + # @runMovement "forward word" + # @runMovement [ "forward", "word" ] + # @runMovement "forward", "word" + # + # The granularities are word, "character", "line", "lineboundary", "sentence" and "paragraph". In addition, + # we implement the pseudo granularity "vimword", which implements vim-like word movement (for "w"). + # + runMovement: (args...) -> + # Normalize the various argument forms. + [ direction, granularity ] = + if typeof(args[0]) == "string" and args.length == 1 + args[0].trim().split /\s+/ + else + if args.length == 1 then args[0] else args[...2] + + # Native word movements behave differently on Linux and Windows, see #1441. So we implement some of them + # character-by-character. + if granularity == vimword and direction == forward + while @nextCharacterIsWordCharacter() + return unless @runMovements [ forward, character ] + while @getNextForwardCharacter() and not @nextCharacterIsWordCharacter() + return unless @runMovements [ forward, character ] + + else if granularity == vimword + @selection.modify @alterMethod, backward, word + + # As above, we implement this character-by-character to get consistent behavior on Windows and Linux. + if granularity == word and direction == forward + while @getNextForwardCharacter() and not @nextCharacterIsWordCharacter() + return unless @runMovements [ forward, character ] + while @nextCharacterIsWordCharacter() + return unless @runMovements [ forward, character ] + + else + @selection.modify @alterMethod, direction, granularity + + # Return a simple camparable value which depends on various aspects of the selection. This is used to + # detect, after a movement, whether the selection has changed. + hashSelection: (debug) -> + range = @selection.getRangeAt(0) + [ @element?.selectionStart, @selection.toString().length, range.anchorOffset, range.focusOffset, + @selection.extentOffset, @selection.baseOffset ].join "/" + + # Call a function; return true if the selection changed, false otherwise. + selectionChanged: (func) -> + before = @hashSelection(); func(); @hashSelection() != before + + # Run a sequence of movements, stopping if a movement fails to change the selection. + runMovements: (movements...) -> + for movement in movements + return false unless @selectionChanged => @runMovement movement + true + + # Swap the anchor node/offset and the focus node/offset. This allows us to work with both ends of the + # selection, and implements "o" for visual mode. + reverseSelection: -> + direction = @getDirection() + element = document.activeElement + if element and DomUtils.isEditable(element) and not element.isContentEditable + # Note(smblott). This implementation is unacceptably expensive if the selection is large. We only use + # it here because the normal method (below) does not work for simple text inputs. + length = @selection.toString().length + @collapseSelectionToFocus() + @runMovement @opposite[direction], character for [0...length] + else + # Normal method (efficient). + original = @selection.getRangeAt(0).cloneRange() + range = original.cloneRange() + range.collapse direction == backward + @setSelectionRange range + which = if direction == forward then "start" else "end" + @selection.extend original["#{which}Container"], original["#{which}Offset"] + + # Try to extend the selection one character in direction. Return positive, negative or 0, indicating + # whether the selection got bigger, or smaller, or is unchanged. + extendByOneCharacter: (direction) -> + length = @selection.toString().length + @selection.modify "extend", direction, character + @selection.toString().length - length + + # Get the direction of the selection. The selection is "forward" if the focus is at or after the anchor, + # and "backward" otherwise. + # NOTE(smblott). This could be better, see: https://dom.spec.whatwg.org/#interface-range (however, that + # probably wouldn't work for text inputs). + getDirection: -> + # Try to move the selection forward or backward, check whether it got bigger or smaller (then restore it). + for direction in [ forward, backward ] + if change = @extendByOneCharacter direction + @extendByOneCharacter @opposite[direction] + return if 0 < change then direction else @opposite[direction] + forward + + collapseSelectionToAnchor: -> + if 0 < @selection.toString().length + @selection[if @getDirection() == backward then "collapseToEnd" else "collapseToStart"]() + + collapseSelectionToFocus: -> + if 0 < @selection.toString().length + @selection[if @getDirection() == forward then "collapseToEnd" else "collapseToStart"]() + + setSelectionRange: (range) -> + @selection.removeAllRanges() + @selection.addRange range + + # A movement can be either a string (which will be passed to @runMovement count times), or a function (which + # will be called once with count as its argument). + movements: + "l": "forward character" + "h": "backward character" + "j": "forward line" + "k": "backward line" + "e": "forward word" + "b": "backward word" + "w": "forward vimword" + ")": "forward sentence" + "(": "backward sentence" + "}": "forward paragraph" + "{": "backward paragraph" + "0": "backward lineboundary" + "$": "forward lineboundary" + "G": "forward documentboundary" + "gg": "backward documentboundary" + "Y": (count) -> @selectLine count; @yank() + + # This handles a movement, but protects to selection while doing so. + runMovementKeyChar: (args...) -> + @protectClipboard => @handleMovementKeyChar args... + + # Handle a single movement keyChar. This is extended (wrapped) by super-classes. + handleMovementKeyChar: (keyChar, count = 1) -> + switch typeof @movements[keyChar] + when "string" then @runMovement @movements[keyChar] for [0...count] + when "function" then @movements[keyChar].call @, count + @scrollIntoView() + + # The bahavior of Movement can be tweaked by setting the following options: + # - options.parentMode (a mode) + # This instance is a sub-mode of another mode (currently, only edit mode). + # - options.oneMovementOnly (truthy/falsy) + # This instance is created for one movement only, after which it yanks and exits. + # - options.immediateMovement (a keyChar string) + # This instance is created for one movement only, and this options specifies the movement (e.g. "j"). + # - options.deleteFromDocument (truthy/falsy) + # When yanking text, also delete it from the document. + # - options.onYank (a function) + # When yanking text, also call this function, passing the yanked text as an argument. + # - options.noCopyToClipboard (truthy/falsy) + # If truthy, then do not copy the yanked text to the clipboard when yanking. + # + constructor: (options) -> + @selection = window.getSelection() + @movements = extend {}, @movements + @commands = {} + @keyQueue = "" + super options + + # Aliases. + @movements.B = @movements.b + @movements.W = @movements.w + + if @options.immediateMovement + # This instance has been created to execute a single, given movement. + @runMovementKeyChar @options.immediateMovement, @getCountPrefix() + return + + # This is the main keyboard-event handler for movements and commands for all user modes (visual, + # visual-line, caret and edit). + @push + _name: "#{@id}/keypress" + keypress: (event) => + unless event.metaKey or event.ctrlKey or event.altKey + @keyQueue += String.fromCharCode event.charCode + # Keep at most two keyChars in the queue. + @keyQueue = @keyQueue.slice Math.max 0, @keyQueue.length - 2 + for command in [ @keyQueue, @keyQueue[1..] ] + if command and (@movements[command] or @commands[command]) + @selection = window.getSelection() + @keyQueue = "" + + # We need to treat "0" specially. It can be either a movement, or a continutation of a count + # prefix. Don't treat it as a movement if we already have an initial count prefix. + return @continueBubbling if command == "0" and 0 < @countPrefix.length + + if @commands[command] + @commands[command].call @, @getCountPrefix() + @scrollIntoView() + return @suppressEvent + + else if @movements[command] + @runMovementKeyChar command, @getCountPrefix() + return @suppressEvent + + @continueBubbling + + # Install basic bindings for find mode, "n" and "N". We do not install these bindings if this is a + # sub-mode of edit mode (because we cannot guarantee that the selection will remain within the active + # element), or if this instance has been created to execute only a single movement. + unless @options.parentMode or options.oneMovementOnly + do => + executeFind = (count, findBackwards) => + if query = getFindModeQuery() + initialRange = @selection.getRangeAt(0).cloneRange() + for [0...count] + unless window.find query, Utils.hasUpperCase(query), findBackwards, true, false, true, false + @setSelectionRange initialRange + HUD.showForDuration("No matches for '" + query + "'", 1000) + return + # The find was successfull. If we're in caret mode, then we should now have a selection, so we can + # drop back into visual mode. + @changeMode VisualMode if @name == "caret" and 0 < @selection.toString().length + + @movements.n = (count) -> executeFind count, false + @movements.N = (count) -> executeFind count, true + @movements["/"] = -> + @findMode = window.enterFindMode() + @findMode.onExit => @changeMode VisualMode + # + # End of Movement constructor. + + # Yank the selection; always exits; either deletes the selection or removes it; set @yankedText and return + # it. + yank: (args = {}) -> + @yankedText = @selection.toString() + @selection.deleteFromDocument() if @options.deleteFromDocument or args.deleteFromDocument + @selection.removeAllRanges() unless @options.parentMode + + message = @yankedText.replace /\s+/g, " " + message = message[...12] + "..." if 15 < @yankedText.length + plural = if @yankedText.length == 1 then "" else "s" + HUD.showForDuration "Yanked #{@yankedText.length} character#{plural}: \"#{message}\".", 2500 + + @options.onYank?.call @, @yankedText + @exit() + @yankedText + + exit: (event, target) -> + unless @options.parentMode or @options.oneMovementOnly + @selection.removeAllRanges() if event?.type == "keydown" and KeyboardUtils.isEscape event + + # Disabled, pending discussion of fine-tuning the UX. Simpler alternative is implemented above. + # # If we're exiting on escape and there is a range selection, then we leave it in place. However, an + # # immediately-following Escape clears the selection. See #1441. + # if @selection.type == "Range" and event?.type == "keydown" and KeyboardUtils.isEscape event + # handlerStack.push + # _name: "visual/range/escape" + # click: -> handlerStack.remove(); @continueBubbling + # focus: -> handlerStack.remove(); @continueBubbling + # keydown: (event) => + # handlerStack.remove() + # if @selection.type == "Range" and event.type == "keydown" and KeyboardUtils.isEscape event + # @collapseSelectionToFocus() + # DomUtils.suppressKeyupAfterEscape handlerStack + # @suppressEvent + # else + # @continueBubbling + + super event, target + + # For "daw", "das", and so on. We select a lexical entity (a word, a sentence or a paragraph). + # Note(smblott). It would be better if the entities could be handled symmetrically. Unfortunately, they + # cannot, and we have to handle each case individually. + selectLexicalEntity: (entity, count = 1) -> + + switch entity + when word + if @nextCharacterIsWordCharacter() + @runMovements [ forward, character ], [ backward, word ] + @collapseSelectionToFocus() + @runMovements ([0...count].map -> [ forward, vimword ])... + + when sentence + @runMovements [ forward, character ], [ backward, sentence ] + @collapseSelectionToFocus() + @runMovements ([0...count].map -> [ forward, sentence ])... + + when paragraph + # Chrome's paragraph movements are weird: they're not symmetrical, and tend to stop in odd places + # (like mid-paragraph, for example). Here, we define a paragraph as a new-line delimited entity, + # including the terminating newline. + # Note(smblott). This does not currently use the count. + char = @getNextBackwardCharacter() + while char? and char != "\n" + return unless @runMovements [ backward, character ], [ backward, lineboundary ] + char = @getNextBackwardCharacter() + @collapseSelectionToFocus() + char = @getNextForwardCharacter() + while char? and char != "\n" + return unless @runMovements [ forward, character ], [ forward, lineboundary ] + char = @getNextForwardCharacter() + @runMovement forward, character + + # Scroll the focus into view. + scrollIntoView: -> + @protectClipboard => + if @element and DomUtils.isEditable @element + if @element.clientHeight < @element.scrollHeight + if @element.isContentEditable + # WIP (edit mode only)... + elementWithFocus = DomUtils.getElementWithFocus @selection, @getDirection() == backward + # position = @element.getClientRects()[0].top - elementWithFocus.getClientRects()[0].top + # console.log "top", position + # Scroller.scrollToPosition @element, position, 0 + position = elementWithFocus.getClientRects()[0].bottom - @element.getClientRects()[0].top - @element.clientHeight + @element.scrollTop + Scroller.scrollToPosition @element, position, 0 + else + position = if @getDirection() == backward then @element.selectionStart else @element.selectionEnd + coords = DomUtils.getCaretCoordinates @element, position + Scroller.scrollToPosition @element, coords.top, coords.left + else + elementWithFocus = DomUtils.getElementWithFocus @selection, @getDirection() == backward + Scroller.scrollIntoView elementWithFocus if elementWithFocus + +class VisualMode extends Movement + constructor: (options = {}) -> + @alterMethod = "extend" + + defaults = + name: "visual" + badge: "V" + singleton: VisualMode + exitOnEscape: true + super extend defaults, options + + # Establish or use the initial selection. If that's not possible, then enter caret mode. + unless @options.oneMovementOnly or options.immediateMovement + if @options.parentMode and @selection.type == "Caret" + # We're being called from edit mode, so establish an intial visible selection. + @extendByOneCharacter(forward) or @extendByOneCharacter backward + else + if @selection.type in [ "Caret", "Range" ] + elementWithFocus = DomUtils.getElementWithFocus @selection, @getDirection() == backward + if DomUtils.getVisibleClientRect elementWithFocus + if @selection.type == "Caret" + # The caret is in the viewport. Make make it visible. + @extendByOneCharacter(forward) or @extendByOneCharacter backward + else + # The selection is outside of the viewport: clear it. We guess that the user has moved on, and is + # more likely to be interested in visible content. + @selection.removeAllRanges() + + if @selection.type != "Range" + HUD.showForDuration "No usable selection, entering caret mode...", 2500 + @changeMode CaretMode + return + + @push + _name: "#{@id}/enter/click" + # Yank on <Enter>. + keypress: (event) => + if event.keyCode == keyCodes.enter + unless event.metaKey or event.ctrlKey or event.altKey or event.shiftKey + @yank() + return @suppressEvent + @continueBubbling + # Click in a focusable element exits. + click: (event) => + @alwaysContinueBubbling => + unless @options.parentMode + @exit event, event.target if DomUtils.isFocusable event.target + + # Visual-mode commands. + unless @options.oneMovementOnly + @commands.y = -> @yank() + @commands.p = -> chrome.runtime.sendMessage handler: "openUrlInCurrentTab", url: @yank() + @commands.P = -> chrome.runtime.sendMessage handler: "openUrlInNewTab", url: @yank() + @commands.V = -> @changeMode VisualLineMode + @commands.c = -> @collapseSelectionToFocus(); @changeMode CaretMode + @commands.o = -> @reverseSelection() + + # Additional commands when run under edit mode. + if @options.parentMode + @commands.x = -> @yank deleteFromDocument: true + @commands.d = -> @yank deleteFromDocument: true + @commands.c = -> @yank deleteFromDocument: true; @options.parentMode.enterInsertMode() + + # For edit mode's "yy" and "dd". + if @options.yankLineCharacter + @commands[@options.yankLineCharacter] = (count) -> + @selectLine count; @yank() + + # For edit mode's "daw", "cas", and so on. + if @options.oneMovementOnly + @commands.a = (count) -> + for entity in [ word, sentence, paragraph ] + do (entity) => + @commands[entity.charAt 0] = -> + @selectLexicalEntity entity, count; @yank() + # + # End of VisualMode constructor. + + exit: (event, target) -> + unless @options.parentMode + # Don't leave the user in insert mode just because they happen to have selected text within an input + # element. + if document.activeElement and DomUtils.isEditable document.activeElement + document.activeElement.blur() unless event?.type == "click" + + super event, target + if @yankedText? + unless @options.noCopyToClipboard + console.log "yank:", @yankedText if @debug + @copy @yankedText, true + + # Call sub-class; then yank, if we've only been created for a single movement. + handleMovementKeyChar: (args...) -> + super args... + @yank() if @options.oneMovementOnly or @options.immediateMovement + + selectLine: (count) -> + @reverseSelection() if @getDirection() == forward + @runMovement backward, lineboundary + @reverseSelection() + @runMovement forward, line for [1...count] + @runMovement forward, lineboundary + # Include the next character if it is a newline. + @runMovement forward, character if @getNextForwardCharacter() == "\n" + +class VisualLineMode extends VisualMode + constructor: (options = {}) -> + super extend { name: "visual/line" }, options + @extendSelection() + @commands.v = -> @changeMode VisualMode + + handleMovementKeyChar: (args...) -> + super args... + @extendSelection() + + extendSelection: -> + initialDirection = @getDirection() + for direction in [ initialDirection, @opposite[initialDirection] ] + @runMovement direction, lineboundary + @reverseSelection() + +class CaretMode extends Movement + constructor: (options = {}) -> + @alterMethod = "move" + + defaults = + name: "caret" + badge: "C" + singleton: VisualMode + exitOnEscape: true + super extend defaults, options + + # Establish the initial caret. + switch @selection.type + when "None" + @establishInitialSelectionAnchor() + if @selection.type == "None" + HUD.showForDuration "Create a selection before entering visual mode.", 2500 + @exit() + return + when "Range" + @collapseSelectionToAnchor() + + @selection.modify "extend", forward, character + @scrollIntoView() + + @push + _name: "#{@id}/click" + # Click in a focusable element exits. + click: (event) => + @alwaysContinueBubbling => + @exit event, event.target if DomUtils.isFocusable event.target + + # Commands to exit caret mode, and enter visual mode. + extend @commands, + v: -> @changeMode VisualMode + V: -> @changeMode VisualLineMode + + handleMovementKeyChar: (args...) -> + @collapseSelectionToAnchor() + super args... + @selection.modify "extend", forward, character + + # When visual mode starts and there's no existing selection, we launch CaretMode and try to establish a + # selection. As a heuristic, we pick the first non-whitespace character of the first visible text node + # which seems to be big enough to be interesting. + # TODO(smblott). It might be better to do something similar to Clearly or Readability; that is, try to find + # the start of the page's main textual content. + establishInitialSelectionAnchor: -> + nodes = document.createTreeWalker document.body, NodeFilter.SHOW_TEXT + while node = nodes.nextNode() + # Don't choose short text nodes; they're likely to be part of a banner. + if node.nodeType == 3 and 50 <= node.data.trim().length + element = node.parentElement + if DomUtils.getVisibleClientRect(element) and not DomUtils.isEditable element + # Start at the offset of the first non-whitespace character. + offset = node.data.length - node.data.replace(/^\s+/, "").length + range = document.createRange() + range.setStart node, offset + range.setEnd node, offset + @setSelectionRange range + return true + false + +class EditMode extends Movement + constructor: (options = {}) -> + @alterMethod = "move" + @element = document.activeElement + return unless @element and DomUtils.isEditable @element + + defaults = + name: "edit" + badge: "E" + exitOnEscape: true + exitOnBlur: @element + super extend defaults, options + + # Edit mode commands. + extend @commands, + i: -> @enterInsertMode() + a: -> @enterInsertMode() + I: -> @runMovement backward, lineboundary; @enterInsertMode() + A: -> @runMovement forward, lineboundary; @enterInsertMode() + o: -> @openLine forward + O: -> @openLine backward + p: -> @pasteClipboard forward + P: -> @pasteClipboard backward + v: -> @launchSubMode VisualMode + V: -> @launchSubMode VisualLineMode + + Y: (count) -> @enterVisualModeForMovement count, immediateMovement: "Y" + x: (count) -> @enterVisualModeForMovement count, immediateMovement: "l", deleteFromDocument: true, noCopyToClipboard: true + X: (count) -> @enterVisualModeForMovement count, immediateMovement: "h", deleteFromDocument: true, noCopyToClipboard: true + y: (count) -> @enterVisualModeForMovement count, yankLineCharacter: "y" + d: (count) -> @enterVisualModeForMovement count, yankLineCharacter: "d", deleteFromDocument: true + c: (count) -> @enterVisualModeForMovement count, deleteFromDocument: true, onYank: => @enterInsertMode() + + D: (count) -> @enterVisualModeForMovement 1, immediateMovement: "$", deleteFromDocument: true + C: (count) -> @enterVisualModeForMovement 1, immediateMovement: "$", deleteFromDocument: true, onYank: => @enterInsertMode() + + '~': (count) -> @swapCase count, true + 'g~': (count) -> @swapCase count, false + + # Disabled. Doesn't work reliably. + # J: (count) -> + # for [0...count] + # @runMovement forward, lineboundary + # @enterVisualModeForMovement 1, immediateMovement: "w", deleteFromDocument: true, noCopyToClipboard: true + # DomUtils.simulateTextEntry @element, " " + + r: (count) -> + handlerStack.push + _name: "repeat-character" + keydown: (event) => DomUtils.suppressPropagation event; @stopBubblingAndFalse + keypress: (event) => + handlerStack.remove() + keyChar = String.fromCharCode event.charCode + if keyChar.length == 1 + @enterVisualModeForMovement count, immediateMovement: "l", deleteFromDocument: true, noCopyToClipboard: true + DomUtils.simulateTextEntry @element, [0...count].map(-> keyChar).join "" + @suppressEvent + + # Disabled: potentially confusing. + # # If the input is empty, then enter insert mode immediately. + # unless @element.isContentEditable + # if @element.value.trim() == "" + # @enterInsertMode() + # HUD.showForDuration "Input empty, entered insert mode directly.", 3500 + # + # End of edit-mode constructor. + + # For "~", "3~", "g~3w", "g~e", and so on. + swapCase: (count, immediate) -> + @enterVisualModeForMovement count, + immediateMovement: if immediate then "l" else null + deleteFromDocument: true + noCopyToClipboard: true + onYank: (text) => + chars = + for char in text.split "" + if char == char.toLowerCase() then char.toUpperCase() else char.toLowerCase() + DomUtils.simulateTextEntry @element, chars.join "" + + # For "p" and "P". + pasteClipboard: (direction) -> + @paste (text) => + if text + # We use the following heuristic: if the text ends with a newline character, then it's a line-oriented + # paste, and should be pasted in at a line break. + if /\n$/.test text + @runMovement backward, lineboundary + @runMovement forward, line if direction == forward + DomUtils.simulateTextEntry @element, text + @runMovement backward, line + else + DomUtils.simulateTextEntry @element, text + + # For "o" and "O". + openLine: (direction) -> + @runMovement direction, lineboundary + DomUtils.simulateTextEntry @element, "\n" + @runMovement backward, character if direction == backward + @enterInsertMode() + + # This lanches a visual-mode instance for one movement only, (usually) yanks the resulting selected text, + # and (possibly) deletes it. + enterVisualModeForMovement: (count, options = {}) -> + @launchSubMode VisualMode, extend options, + badge: "M" + initialCountPrefix: count + oneMovementOnly: true + + enterInsertMode: () -> + @launchSubMode InsertMode, + exitOnEscape: true + targetElement: @options.targetElement + + launchSubMode: (mode, options = {}) -> + @activeSubMode?.instance.exit() + @activeSubMode = + mode: mode + options: options + instance: new mode extend options, parentMode: @ + @activeSubMode.instance.onExit => @activeSubMode = null + + exit: (event, target) -> + super event, target + + # Deactivate any active sub-mode. Any such mode will clear @activeSubMode on exit, so we grab a copy now. + activeSubMode = @activeSubMode + activeSubMode?.instance.exit() + + if event?.type == "keydown" and KeyboardUtils.isEscape event + if target? and DomUtils.isDOMDescendant @element, target + @element.blur() + + if event?.type == "blur" + # This instance of edit mode has now been entirely removed from the handler stack. It is inactive. + # However, the user hasn't asked to leave edit mode, and may return. For example, we get a blur event + # when we change tab. Or, the user may be copying text with the mouse. When the user does return, + # they expect to still be in edit mode. We leave behind a "suspended-edit" mode which watches for focus + # events and activates a new edit-mode instance if required. + # + # How does this get cleaned up? It's a bit tricky. The suspended-edit mode remains active on the + # current input element indefinitely. However, the only way to enter edit mode is via focusInput. And + # all modes launched by focusInput on a particular input element share a singleton (the element itself). + # In addition, the new mode below shares the same singleton. So any new insert-mode or edit-mode + # instance on this target element (the singleton) displaces any previously-active mode (including any + # suspended-edit mode). PostFindMode shares the same singleton. + # + (new Mode name: "#{@id}-suspended", singleton: @options.singleton).push + _name: "suspended-edit/#{@id}/focus" + focus: (event) => + @alwaysContinueBubbling => + if event?.target == @options.targetElement + editMode = new EditMode Utils.copyObjectOmittingProperties @options, "keydown", "keypress", "keyup" + editMode.launchSubMode activeSubMode.mode, activeSubMode.options if activeSubMode + +root = exports ? window +root.VisualMode = VisualMode +root.VisualLineMode = VisualLineMode +root.EditMode = EditMode diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index fbc34794..5cc3fd82 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -146,7 +146,7 @@ CoreScroller = calibrationBoundary: 150 # Boundary between scrolls which are considered too slow, or too fast. # Scroll element by a relative amount (a number) in some direction. - scroll: (element, direction, amount) -> + scroll: (element, direction, amount, continuous = true) -> return unless amount unless @settings.get "smoothScroll" @@ -202,10 +202,14 @@ CoreScroller = # We're done. checkVisibility element + # If we've been asked not to be continuous, then we advance time, so the myKeyIsStillDown test always + # fails. + ++@time unless continuous + # Launch animator. requestAnimationFrame animate -# Scroller contains the two main scroll functions (scrollBy and scrollTo) which are exported to clients. +# Scroller contains the two main scroll functions which are used by clients. Scroller = init: (frontendSettings) -> handlerStack.push @@ -242,5 +246,52 @@ Scroller = amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] CoreScroller.scroll element, direction, amount + # Scroll the top, bottom, left and right of element into view. The is used by visual mode to ensure the + # focus remains visible. + scrollIntoView: (element) -> + activatedElement ||= document.body and firstScrollableElement() + rect = element. getClientRects()?[0] + if rect? + # Scroll y axis. + if rect.top < 0 + amount = rect.top - 10 + element = findScrollableElement element, "y", amount, 1 + CoreScroller.scroll element, "y", amount, false + else if window.innerHeight < rect.bottom + amount = rect.bottom - window.innerHeight + 10 + element = findScrollableElement element, "y", amount, 1 + CoreScroller.scroll element, "y", amount, false + + # Scroll x axis. + if rect.left < 0 + amount = rect.left - 10 + element = findScrollableElement element, "x", amount, 1 + CoreScroller.scroll element, "x", amount, false + else if window.innerWidth < rect.right + amount = rect.right - window.innerWidth + 10 + element = findScrollableElement element, "x", amount, 1 + CoreScroller.scroll element, "x", amount, false + + # Scroll element to position top, left. This is used by edit mode to ensure that the caret remains visible + # in text inputs (not contentEditable). + scrollToPosition: (element, top, left) -> + activatedElement ||= document.body and firstScrollableElement() + + # Scroll down, "y". + amount = top + 20 - (element.clientHeight + element.scrollTop) + CoreScroller.scroll element, "y", amount, false if 0 < amount + + # Scroll up, "y". + amount = top - (element.scrollTop) - 5 + CoreScroller.scroll element, "y", amount, false if amount < 0 + + # Scroll down, "x". + amount = left + 20 - (element.clientWidth + element.scrollLeft) + CoreScroller.scroll element, "x", amount, false if 0 < amount + + # Scroll up, "x". + amount = left - (element.scrollLeft) - 5 + CoreScroller.scroll element, "x", amount, false if amount < 0 + root = exports ? window root.Scroller = Scroller diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index b97b26c9..4fdf58bd 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -309,9 +309,15 @@ extend window, enterInsertMode: -> new InsertMode global: true - enterVisualMode: => + enterVisualMode: -> new VisualMode() + enterVisualLineMode: -> + new VisualLineMode + + enterEditMode: -> + @focusInput 1, EditMode + focusInput: do -> # Track the most recently focused input element. recentlyFocusedElement = null @@ -319,10 +325,11 @@ extend window, (event) -> recentlyFocusedElement = event.target if DomUtils.isEditable event.target , true - (count) -> + (count, mode = InsertMode) -> # 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. + # The mode argument is the mode to enter once an input is selected. resultSet = DomUtils.evaluateXPath textInputXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE visibleInputs = for i in [0...resultSet.snapshotLength] by 1 @@ -360,9 +367,6 @@ extend window, 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 @@ -370,23 +374,36 @@ extend window, selectedInputIndex += hints.length + (if event.shiftKey then -1 else 1) selectedInputIndex %= hints.length hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' + # Deactivate any active modes on this element (PostFindMode, or a suspended edit mode). + @deactivateSingleton visibleInputs[selectedInputIndex].element visibleInputs[selectedInputIndex].element.focus() @suppressEvent else unless event.keyCode == KeyboardUtils.keyCodes.shiftKey @exit() - @continueBubbling + # Give the new mode the opportunity to handle the event. + @restartBubbling - @onExit -> DomUtils.removeElement hintContainingDiv - hintContainingDiv = DomUtils.addElementList hints, + @hintContainingDiv = DomUtils.addElementList hints, id: "vimiumInputMarkerContainer" className: "vimiumReset" + # Deactivate any active modes on this element (PostFindMode, or a suspended edit mode). + @deactivateSingleton visibleInputs[selectedInputIndex].element visibleInputs[selectedInputIndex].element.focus() if visibleInputs.length == 1 @exit() + return else hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' + exit: -> + super() + DomUtils.removeElement @hintContainingDiv + if mode and document.activeElement and DomUtils.isEditable document.activeElement + new mode + singleton: document.activeElement + targetElement: document.activeElement + # Track which keydown events we have handled, so that we can subsequently suppress the corresponding keyup # event. KeydownEvents = @@ -760,18 +777,20 @@ getNextQueryFromRegexMatches = (stepSize) -> findModeQuery.regexMatches[findModeQuery.activeRegexIndex] -findAndFocus = (backwards) -> +window.getFindModeQuery = -> # check if the query has been changed by a script in another frame mostRecentQuery = FindModeHistory.getQuery() if (mostRecentQuery != findModeQuery.rawQuery) findModeQuery.rawQuery = mostRecentQuery updateFindModeQuery() - query = - if findModeQuery.isRegex - getNextQueryFromRegexMatches(if backwards then -1 else 1) - else - findModeQuery.parsedQuery + if findModeQuery.isRegex + getNextQueryFromRegexMatches(if backwards then -1 else 1) + else + findModeQuery.parsedQuery + +findAndFocus = (backwards) -> + query = getFindModeQuery() findModeQueryHasResults = executeFind(query, { backwards: backwards, caseSensitive: !findModeQuery.ignoreCase }) @@ -915,6 +934,7 @@ findModeRestoreSelection = (range = findModeInitialRange) -> selection.removeAllRanges() selection.addRange range +# Enters find mode. Returns the new find-mode instance. window.enterFindMode = -> # Save the selection, so performFindInPlace can restore it. findModeSaveSelection() diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index 4f36e395..2ae9412e 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -231,5 +231,69 @@ DomUtils = @remove() false + simulateTextEntry: (element, text) -> + event = document.createEvent "TextEvent" + event.initTextEvent "textInput", true, true, null, text + element.dispatchEvent event + + # Adapted from: http://roysharon.com/blog/37. + # This finds the element containing the selection focus. + getElementWithFocus: (selection, backwards) -> + r = t = selection.getRangeAt 0 + if selection.type == "Range" + r = t.cloneRange() + r.collapse backwards + t = r.startContainer + t = t.childNodes[r.startOffset] if t.nodeType == 1 + o = t + o = o.previousSibling while o and o.nodeType != 1 + t = o || t?.parentNode + t + + # This calculates the caret coordinates within an input element. It is used by edit mode to calculate the + # caret position for scrolling. It creates a hidden div contain a mirror of element, and all of the text + # from element up to position, then calculates the scroll position. + # From: https://github.com/component/textarea-caret-position/blob/master/index.js + getCaretCoordinates: do -> + # The properties that we copy to the mirrored div. + properties = [ + 'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY', + 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', + 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', + 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', + 'lineHeight', 'fontFamily', + 'textAlign', 'textTransform', 'textIndent', 'textDecoration', + 'letterSpacing', 'wordSpacing' ] + + (element, position) -> + div = document.createElement "div" + div.id = "vimium-input-textarea-caret-position-mirror-div" + document.body.appendChild div + + style = div.style + computed = getComputedStyle element + + style.whiteSpace = "pre-wrap" + style.wordWrap = "break-word" if element.nodeName.toLowerCase() != "input" + style.position = "absolute" + style.visibility = "hidden" + style[prop] = computed[prop] for prop in properties + style.overflow = "hidden" + + div.textContent = element.value.substring 0, position + if element.nodeName.toLowerCase() == "input" + div.textContent = div.textContent.replace /\s/g, "\u00a0" + + span = document.createElement "span" + span.textContent = element.value.substring(position) || "." + div.appendChild span + + coordinates = + top: span.offsetTop + parseInt computed["borderTopWidth"] + left: span.offsetLeft + parseInt computed["borderLeftWidth"] + + document.body.removeChild div + coordinates + root = exports ? window root.DomUtils = DomUtils diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee index 3d635005..b0fefc7d 100644 --- a/lib/handler_stack.coffee +++ b/lib/handler_stack.coffee @@ -38,6 +38,7 @@ class HandlerStack # @stopBubblingAndTrue. bubbleEvent: (type, event) -> @eventNumber += 1 + eventNumber = @eventNumber # We take a copy of the array in order to avoid interference from concurrent removes (for example, to # avoid calling the same handler twice, because elements have been spliced out of the array by remove). for handler in @stack[..].reverse() @@ -45,13 +46,15 @@ class HandlerStack if handler?.id and handler[type] @currentId = handler.id result = handler[type].call @, event - @logResult type, event, handler, result if @debug + @logResult eventNumber, type, event, handler, result if @debug if not result DomUtils.suppressEvent event if @isChromeEvent event return false return true if result == @stopBubblingAndTrue return false if result == @stopBubblingAndFalse return @bubbleEvent type, event if result == @restartBubbling + else + @logResult eventNumber, type, event, handler, "skip" if @debug true remove: (id = @currentId) -> @@ -80,7 +83,7 @@ class HandlerStack false # Debugging. - logResult: (type, event, handler, result) -> + logResult: (eventNumber, type, event, handler, result) -> # FIXME(smblott). Badge updating is too noisy, so we filter it out. However, we do need to look at how # many badge update events are happening. It seems to be more than necessary. We also filter out # registerKeyQueue as unnecessarily noisy and not particularly helpful. @@ -90,9 +93,15 @@ class HandlerStack when @stopBubblingAndTrue then "stop/true" when @stopBubblingAndFalse then "stop/false" when @restartBubbling then "rebubble" + when "skip" then "skip" when true then "continue" label ||= if result then "continue/truthy" else "suppress" - console.log "#{@eventNumber}", type, handler._name, label + console.log "#{eventNumber}", type, handler._name, label + + show: -> + console.log "#{@eventNumber}:" + for handler in @stack[..].reverse() + console.log " ", handler._name # For tests only. reset: -> diff --git a/lib/keyboard_utils.coffee b/lib/keyboard_utils.coffee index 693c9b1c..5c95680c 100644 --- a/lib/keyboard_utils.coffee +++ b/lib/keyboard_utils.coffee @@ -1,6 +1,6 @@ KeyboardUtils = keyCodes: - { ESC: 27, backspace: 8, deleteKey: 46, enter: 13, space: 32, shiftKey: 16, ctrlKey: 17, f1: 112, + { ESC: 27, backspace: 8, deleteKey: 46, enter: 13, ctrlEnter: 10, space: 32, shiftKey: 16, ctrlKey: 17, f1: 112, f12: 123, tab: 9, downArrow: 40, upArrow: 38 } keyNames: @@ -59,7 +59,12 @@ KeyboardUtils = # identify any of chrome's own keyboard shortcuts as printable. isPrintable: (event) -> return false if event.metaKey or event.ctrlKey or event.altKey - @getKeyChar(event)?.length == 1 + keyChar = + if event.type == "keypress" + String.fromCharCode event.charCode + else + @getKeyChar event + keyChar.length == 1 KeyboardUtils.init() diff --git a/lib/utils.coffee b/lib/utils.coffee index 661f7e84..64c87842 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -152,6 +152,23 @@ Utils = # locale-sensitive uppercase detection hasUpperCase: (s) -> s.toLowerCase() != s + # Give objects (including elements) distinct identities. + getIdentity: do -> + identities = [] + + (obj) -> + index = identities.indexOf obj + if index < 0 + index = identities.length + identities.push obj + "identity-" + index + + # Return a copy of object, but with some of its properties omitted. + copyObjectOmittingProperties: (obj, properties...) -> + obj = extend {}, obj + delete obj[property] for property in properties + obj + # This creates a new function out of an existing function, where the new function takes fewer arguments. This # allows us to pass around functions instead of functions + a partial list of arguments. Function::curry = -> diff --git a/manifest.json b/manifest.json index a04d8c0e..beb68530 100644 --- a/manifest.json +++ b/manifest.json @@ -47,7 +47,7 @@ "content_scripts/mode_insert.js", "content_scripts/mode_passkeys.js", "content_scripts/mode_find.js", - "content_scripts/mode_visual.js", + "content_scripts/mode_visual_edit.js", "content_scripts/vimium_frontend.js" ], "css": ["content_scripts/vimium.css"], |
