diff options
| -rw-r--r-- | background_scripts/commands.coffee | 3 | ||||
| -rw-r--r-- | content_scripts/mode_insert.coffee | 6 | ||||
| -rw-r--r-- | content_scripts/mode_visual_edit.coffee | 136 | ||||
| -rw-r--r-- | content_scripts/scroller.coffee | 13 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 3 | ||||
| -rw-r--r-- | lib/dom_utils.coffee | 94 | ||||
| -rw-r--r-- | lib/handler_stack.coffee | 12 | 
7 files changed, 197 insertions, 70 deletions
| diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index f8167042..f79f495b 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -198,7 +198,10 @@ defaultKeyMappings =    "i": "enterInsertMode"    "v": "enterVisualMode" +  # NOTE(smblott).  We'll try two default bindings for enterEditMode, and see which ends up feeling mode +  # natural.    "gv": "enterEditMode" +  "e": "enterEditMode"    "H": "goBack"    "L": "goForward" diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee index eac4a3d0..818c8408 100644 --- a/content_scripts/mode_insert.coffee +++ b/content_scripts/mode_insert.coffee @@ -10,10 +10,11 @@ class InsertMode extends Mode      handleKeyEvent = (event) =>        return @continueBubbling unless @isActive event +      console.log "key", String.fromCharCode(event.charCode) if event.type == 'keypress'        return @stopBubblingAndTrue unless event.type == 'keydown' and KeyboardUtils.isEscape event        DomUtils.suppressKeyupAfterEscape handlerStack        target = event.srcElement -      if target and DomUtils.isFocusable target +      if target and DomUtils.isFocusable(target) and @options.blurOnEscape          # Remove the focus, so the user can't just get back into insert mode by typing in the same input box.          # NOTE(smblott, 2014/12/22) Including embeds for .blur() etc. here is experimental.  It appears to be          # the right thing to do for most common use cases.  However, it could also cripple flash-based sites and @@ -27,6 +28,7 @@ class InsertMode extends Mode        keypress: handleKeyEvent        keyup: handleKeyEvent        keydown: handleKeyEvent +      blurOnEscape: true      super extend defaults, options @@ -38,6 +40,7 @@ class InsertMode extends Mode          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 @@ -74,6 +77,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_edit.coffee b/content_scripts/mode_visual_edit.coffee index 657ae677..e6ea968a 100644 --- a/content_scripts/mode_visual_edit.coffee +++ b/content_scripts/mode_visual_edit.coffee @@ -7,8 +7,12 @@ class SuppressPrintable extends Mode      handler = (event) =>        if KeyboardUtils.isPrintable event          if event.type == "keydown" -          DomUtils.suppressPropagation -          @stopBubblingAndFalse +          # Completely suppress Backspace and Delete. +          if event.keyCode in [ 8, 46 ] +            @suppressEvent +          else +            DomUtils.suppressPropagation +            @stopBubblingAndFalse          else            false        else @@ -59,6 +63,13 @@ class Movement extends MaintainCount      forward: backward      backward: forward +  # Call a function.  Return true if the selection changed. +  selectionChanged: (func) -> +    r = @selection.getRangeAt(0).cloneRange() +    func() +    rr = @selection.getRangeAt(0) +    not (r.compareBoundaryPoints(Range.END_TO_END, rr) and r.compareBoundaryPoints Range.START_TO_START, rr) +    # Try to move one character in "direction".  Return 1, -1 or 0, indicating that the selection got bigger, or    # smaller, or is unchanged.    moveInDirection: (direction) -> @@ -84,19 +95,19 @@ class Movement extends MaintainCount        text.charAt if @getDirection() == forward then text.length - 1 else 0    moveByWord: (direction) -> -    # We go to the end of the next word, then come back to the start of it. -    movements = [ "#{direction} word", "#{@opposite[direction]} word" ] -    # If we're in the middle of a word, then we also need to skip over that one. -    movements.unshift "#{direction} word" unless /\s/.test @nextCharacter direction -    @runMovements movements +    @runMovement "#{direction} word" unless /\s/.test @nextCharacter direction +    while /\s/.test @nextCharacter direction +      break unless @selectionChanged => +        @runMovement "#{direction} character" -  # Run a movement command.  Return true if the length of the selection changed, false otherwise. +  # Run a movement command.    runMovement: (movement) ->      length = @selection.toString().length      @selection.modify @alterMethod, movement.split(" ")... -    @selection.toString().length != length +    @alterMethod == "move" or @selection.toString().length != length    runMovements: (movements) -> +    console.log movements      for movement in movements        break unless @runMovement movement @@ -107,6 +118,7 @@ class Movement extends MaintainCount      "k": "backward line"      "e": "forward word"      "b": "backward word" +    "B": "backward word"      ")": "forward sentence"      "(": "backward sentence"      "}": "forward paragraph" @@ -115,44 +127,16 @@ class Movement extends MaintainCount      "0": "backward lineboundary"      "G": "forward documentboundary"      "g": "backward documentboundary" -      "w": -> @moveByWord forward -    "W": -> @moveByWord backward +    "W": -> @moveByWord forward      "o": -> -      # Swap the anchor and focus. +      # Swap the anchor and focus.  This is too slow if the selection is large. +      direction = @getDirection()        length = @selection.toString().length -      switch @getDirection() -        when forward -          @selection.collapseToEnd() -          # FIXME(smblott). This is super slow if the selection is large. -          @selection.modify "extend", backward, character for [0...length] -        when backward -          @selection.collapseToStart() -          @selection.modify "extend", forward, character for [0...length] -          # Faster, but doesn't always work... -          # @selection.extend @selection.anchorNode, length -      return -      # Note(smblott). I can't find an efficient approach which works for all cases, so we have to implement -      # each case separately. -      # FIXME: This is broken if the selection is in an input area. -      original = @selection.getRangeAt 0 -      switch @getDirection() -        when forward -          range = original.cloneRange() -          range.collapse false -          @selection.removeAllRanges() -          @selection.addRange range -          @selection.extend original.startContainer, original.startOffset -        when backward -          range = document.createRange() -          range.setStart @selection.focusNode, @selection.focusOffset -          range.setEnd @selection.anchorNode, @selection.anchorOffset -          @selection.removeAllRanges() -          @selection.addRange range -      return +      @selection[if direction == forward then "collapseToEnd" else "collapseToStart"]() +      @selection.modify "extend", @opposite[direction], character for [0...length] -  # TODO(smblott). What do we do if there is no initial selection?  Or multiple ranges?    constructor: (options) ->      @alterMethod = options.alterMethod || "extend"      super options @@ -171,10 +155,7 @@ class Movement extends MaintainCount                      @runMovement @movements[keyChar]                    when "function"                      @movements[keyChar].call @ -              # Try to scroll the leading end of the selection into view.  getLeadingElement() seems to work -              # most, but not all, of the time. -              leadingElement = @getLeadingElement @selection -              Scroller.scrollIntoView leadingElement if leadingElement +              @scrollIntoView()    # Adapted from: http://roysharon.com/blog/37.    # I have no idea how this works (smblott, 2015/1/22). @@ -190,6 +171,21 @@ class Movement extends MaintainCount      t = o || t?.parentNode      t +  # Try to scroll the leading end of the selection into view. +  scrollIntoView: -> +    if document.activeElement and DomUtils.isEditable document.activeElement +      element = document.activeElement +      if element.clientHeight < element.scrollHeight +        if element.isContentEditable +          # How do we do this? +        else +          coords = DomUtils.getCaretCoordinates element, element.selectionStart +          Scroller.scrollToPosition element, coords.top, coords.left +    else +      # getLeadingElement() seems to work most, but not all, of the time. +      leadingElement = @getLeadingElement @selection +      Scroller.scrollIntoView leadingElement if leadingElement +  class VisualMode extends Movement    constructor: (options = {}) ->      @selection = window.getSelection() @@ -207,7 +203,6 @@ class VisualMode extends Movement        name: "visual"        badge: "V"        exitOnEscape: true -      exitOnBlur: options.targetElement        alterMethod: "extend"        keypress: (event) => @@ -220,16 +215,18 @@ class VisualMode extends Movement                    handler: "copyToClipboard"                    data: text                  @exit() -                handlerStack.push keyup: => false                  length = text.length                  suffix = if length == 1 then "" else "s"                  text = text[...12] + "..." if 15 < length +                text = text.replace /\n/g, " "                  HUD.showForDuration "Yanked #{length} character#{suffix}: \"#{text}\".", 2500      super extend defaults, options      @debug = true -    # FIXME(smblott). +    # FIXME(smblott).  We can't handle the selection changing with the mouse while while visual-mode is +    # active.  This "fix" doesn't work. +    # work.      # onMouseUp = (event) =>      #   @alwaysContinueBubbling =>      #     if event.which == 1 @@ -249,33 +246,42 @@ class EditMode extends Movement    constructor: (options = {}) ->      defaults =        name: "edit" +      badge: "E"        exitOnEscape: true        alterMethod: "move" -      keydown: (event) => if @isActive() then @handleKeydown event else @continueBubbling -      keypress: (event) => if @isActive() then @handleKeypress event else @continueBubbling -      keyup: (event) => if @isActive() then @handleKeyup event else @continueBubbling +    @debug = true      @element = document.activeElement -    if @element and DomUtils.isEditable @element -      super extend defaults, options - -  handleKeydown: (event) -> -    @stopBubblingAndTrue -  handleKeypress: (event) -> -    @suppressEvent -  handleKeyup: (event) -> -    @stopBubblingAndTrue +    return unless @element and DomUtils.isEditable @element +    super extend defaults, options +    handlerStack.debug = true -  isActive: -> -    document.activeElement and DomUtils.isDOMDescendant @element, document.activeElement +    extend @movements, +      "i": => @enterInsertMode() +      "a": => @enterInsertMode() +      "o": => @openLine forward +      "O": => @openLine backward    exit: (event, target) ->      super()      @element.blur() if target? and DomUtils.isDOMDescendant @element, target      EditMode.activeElements = EditMode.activeElements.filter (element) => element != @element -  updateBadge: (badge) -> -    badge.badge = "E" if @isActive() +  enterInsertMode: -> +    new InsertMode +      badge: "I" +      blurOnEscape: false + +  openLine: (direction) -> +    @runMovement "#{direction} lineboundary" +    @enterInsertMode() +    @simulateTextEntry "\n" +    @runMovement "backward character" if direction == backward + +  simulateTextEntry: (text) -> +    event = document.createEvent "TextEvent" +    event.initTextEvent "textInput", true, true, null, text +    document.activeElement.dispatchEvent event  root = exports ? window  root.VisualMode = VisualMode diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index f31c4a6b..43fad87e 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -254,5 +254,18 @@ Scroller =      else if window.innerHeight < rect.bottom        CoreScroller.scroll activatedElement, "y", 50 + rect.bottom - window.innerHeight, false +  scrollToPosition: (element, top, left) -> +    padding = 20 +    bottom = top + padding +    right = left + padding + +    element.scrollTop = top if top <= element.scrollTop +    element.scrollLeft = left if left <= element.scrollLeft + +    if element.scrollTop + element.clientHeight <= bottom +      element.scrollTop = bottom - element.clientHeight +    if element.scrollLeft + element.clientWidth <= right +      element.scrollLeft = right - element.clientWidth +  root = exports ? window  root.Scroller = Scroller diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 79302930..fefb64ba 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -397,7 +397,8 @@ extend window,                  @suppressEvent                else unless event.keyCode == KeyboardUtils.keyCodes.shiftKey                  @exit() -                @continueBubbling +                # Give the new mode the opportunity to handle the event. +                @restartBubbling            @hintContainingDiv = DomUtils.addElementList hints,              id: "vimiumInputMarkerContainer" diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index 4f36e395..477abef2 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -231,5 +231,99 @@ DomUtils =          @remove()          false +extend DomUtils, +  # From: https://github.com/component/textarea-caret-position/blob/master/index.js +  getCaretCoordinates: do -> +    # The properties that we copy into a mirrored div. +    # Note that some browsers, such as Firefox, +    # do not concatenate properties, i.e. padding-top, bottom etc. -> padding, +    # so we have to do every single property specifically. +    properties = [ +      'direction',  # RTL support +      'boxSizing', +      'width',  # on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does +      'height', +      'overflowX', +      'overflowY',  # copy the scrollbar for IE + +      'borderTopWidth', +      'borderRightWidth', +      'borderBottomWidth', +      'borderLeftWidth', + +      'paddingTop', +      'paddingRight', +      'paddingBottom', +      'paddingLeft', + +      # https://developer.mozilla.org/en-US/docs/Web/CSS/font +      'fontStyle', +      'fontVariant', +      'fontWeight', +      'fontStretch', +      'fontSize', +      'fontSizeAdjust', +      'lineHeight', +      'fontFamily', + +      'textAlign', +      'textTransform', +      'textIndent', +      'textDecoration',  # might not make a difference, but better be safe + +      'letterSpacing', +      'wordSpacing' +    ] + +    `function (element, position, recalculate) { +      // mirrored div +      var div = document.createElement('div'); +      div.id = 'input-textarea-caret-position-mirror-div'; +      document.body.appendChild(div); + +      var style = div.style; +      var computed = window.getComputedStyle? getComputedStyle(element) : element.currentStyle;  // currentStyle for IE < 9 + +      // default textarea styles +      style.whiteSpace = 'pre-wrap'; +      if (element.nodeName !== 'INPUT') +        style.wordWrap = 'break-word';  // only for textarea-s + +      // position off-screen +      style.position = 'absolute';  // required to return coordinates properly +      style.visibility = 'hidden';  // not 'display: none' because we want rendering + +      // transfer the element's properties to the div +      properties.forEach(function (prop) { +        style[prop] = computed[prop]; +      }); + +      style.overflow = 'hidden';  // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' + +      div.textContent = element.value.substring(0, position); +      // the second special handling for input type="text" vs textarea: spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037 +      if (element.nodeName === 'INPUT') +        div.textContent = div.textContent.replace(/\s/g, "\u00a0"); + +      var span = document.createElement('span'); +      // Wrapping must be replicated *exactly*, including when a long word gets +      // onto the next line, with whitespace at the end of the line before (#7). +      // The  *only* reliable way to do that is to copy the *entire* rest of the +      // textarea's content into the <span> created at the caret position. +      // for inputs, just '.' would be enough, but why bother? +      span.textContent = element.value.substring(position) || '.';  // || because a completely empty faux span doesn't render at all +      div.appendChild(span); + +      var coordinates = { +        top: span.offsetTop + parseInt(computed['borderTopWidth']), +        left: span.offsetLeft + parseInt(computed['borderLeftWidth']) +      }; + +      document.body.removeChild(div); + +      return coordinates; +    } +    ` +  root = exports ? window  root.DomUtils = DomUtils diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee index 76d835b7..9630759c 100644 --- a/lib/handler_stack.coffee +++ b/lib/handler_stack.coffee @@ -39,6 +39,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() @@ -46,7 +47,7 @@ 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 @@ -81,7 +82,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. @@ -93,7 +94,12 @@ class HandlerStack          when @restartBubbling then "rebubble"          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  root.HandlerStack = HandlerStack  root.handlerStack = new HandlerStack() | 
