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"], | 
