diff options
| author | Stephen Blott | 2015-01-22 16:50:58 +0000 |
|---|---|---|
| committer | Stephen Blott | 2015-01-23 09:53:49 +0000 |
| commit | 256beee031efef70f4ee750044d9e697d66868bd (patch) | |
| tree | 851b8aa8abdb3b5875d5caa52166d83f13ca89b8 | |
| parent | eefe8c29b2410119412984301eba8c66dffda059 (diff) | |
| download | vimium-256beee031efef70f4ee750044d9e697d66868bd.tar.bz2 | |
Visual/edit modes: develop edit mode.
- implement "i", "a".
- fix "w" for edit mode.
- try out "e" for enter edit mode.
- initial implementation "o", "O"
- Suppress backspace and delete.
- Scroll in text areas.
| -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() |
