aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorStephen Blott2015-01-23 11:02:31 +0000
committerStephen Blott2015-01-23 15:41:02 +0000
commite1b7b0a963490b0991d72a0143f489e0bc1e8096 (patch)
treedc10bf7994b1ccc259af65aea9c029908cccd7d3
parent256beee031efef70f4ee750044d9e697d66868bd (diff)
downloadvimium-e1b7b0a963490b0991d72a0143f489e0bc1e8096.tar.bz2
Visual/edit modes: more (and better) commands.
-rw-r--r--background_scripts/commands.coffee3
-rw-r--r--content_scripts/mode_insert.coffee1
-rw-r--r--content_scripts/mode_visual_edit.coffee267
-rw-r--r--lib/clipboard.coffee4
-rw-r--r--lib/dom_utils.coffee5
5 files changed, 151 insertions, 129 deletions
diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee
index f79f495b..67c4b9ad 100644
--- a/background_scripts/commands.coffee
+++ b/background_scripts/commands.coffee
@@ -198,9 +198,6 @@ 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"
diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee
index 818c8408..741f36cd 100644
--- a/content_scripts/mode_insert.coffee
+++ b/content_scripts/mode_insert.coffee
@@ -10,7 +10,6 @@ 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
diff --git a/content_scripts/mode_visual_edit.coffee b/content_scripts/mode_visual_edit.coffee
index e6ea968a..e2e9c5fe 100644
--- a/content_scripts/mode_visual_edit.coffee
+++ b/content_scripts/mode_visual_edit.coffee
@@ -3,7 +3,6 @@
# through chrome keyboard shortcuts. It's a backstop for all of the modes following.
class SuppressPrintable extends Mode
constructor: (options) ->
-
handler = (event) =>
if KeyboardUtils.isPrintable event
if event.type == "keydown"
@@ -29,7 +28,7 @@ class SuppressPrintable extends Mode
super options
@onExit => handlerStack.remove @suppressPrintableHandlerId
-# This watches keyboard events and maintains @countPrefix as number and other keys are pressed.
+# This watches keyboard events and maintains @countPrefix as number keys and other keys are pressed.
class MaintainCount extends SuppressPrintable
constructor: (options) ->
@countPrefix = ""
@@ -51,6 +50,7 @@ class MaintainCount extends SuppressPrintable
count = if 0 < @countPrefix.length then parseInt @countPrefix else 1
func() for [0...count]
+# Some symbolic names.
forward = "forward"
backward = "backward"
character = "character"
@@ -58,56 +58,50 @@ character = "character"
# This implements movement commands with count prefixes (using MaintainCount) for both visual mode and edit
# mode.
class Movement extends MaintainCount
-
- opposite:
- forward: backward
- backward: forward
+ opposite: { 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)
+ not (r.compareBoundaryPoints(Range.END_TO_END, rr) or 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.
+ # Try to move one character in "direction". Return 1, -1 or 0, indicating whether the selection got bigger,
+ # or smaller, or is unchanged.
moveInDirection: (direction) ->
length = @selection.toString().length
@selection.modify "extend", direction, character
@selection.toString().length - length
- # Get the direction of the selection, either forward or backward.
- # FIXME(smblott). There has to be a better way!
- # NOTE(smblott). There is. See here: https://dom.spec.whatwg.org/#interface-range.
+ # Get the direction of the selection. The selection is "forward" if the focus is at or after the anchor,
+ # and "backward" otherwise.
+ # NOTE(smblott). Could be better, see: https://dom.spec.whatwg.org/#interface-range.
getDirection: ->
- # Try to move the selection forward or backward, then check whether it got bigger or smaller (then restore
- # it).
+ # Try to move the selection forward or backward, check whether it got bigger or smaller (then restore it).
for type in [ forward, backward ]
if success = @moveInDirection type
@moveInDirection @opposite[type]
return if 0 < success then type else @opposite[type]
- nextCharacter: (direction) ->
- if @moveInDirection direction
- text = @selection.toString()
- @moveInDirection @opposite[direction]
- text.charAt if @getDirection() == forward then text.length - 1 else 0
+ moveForwardWord: (direction) ->
+ # We use two forward words and one backword so that we end up at the end of the word if we are at the end
+ # of the text. Currently broken if the very-next characters is whitespace.
+ movements = [ "forward word", "forward word", "forward character", "backward character", "backward word" ]
+ @runMovements movements
- moveByWord: (direction) ->
- @runMovement "#{direction} word" unless /\s/.test @nextCharacter direction
- while /\s/.test @nextCharacter direction
- break unless @selectionChanged =>
- @runMovement "#{direction} character"
+ swapFocusAndAnchor: ->
+ direction = @getDirection()
+ length = @selection.toString().length
+ @selection[if direction == forward then "collapseToEnd" else "collapseToStart"]()
+ @selection.modify "extend", @opposite[direction], character for [0...length]
- # Run a movement command.
+ # Run a movement command. Return true if the selection changed, false otherwise.
runMovement: (movement) ->
- length = @selection.toString().length
- @selection.modify @alterMethod, movement.split(" ")...
- @alterMethod == "move" or @selection.toString().length != length
+ @selectionChanged => @selection.modify @alterMethod, movement.split(" ")...
+ # Run a sequence of movements; bail immediately on any failure to change the selection.
runMovements: (movements) ->
- console.log movements
for movement in movements
break unless @runMovement movement
@@ -125,45 +119,62 @@ class Movement extends MaintainCount
"{": "backward paragraph"
"$": "forward lineboundary"
"0": "backward lineboundary"
+ "w": -> @moveForwardWord()
+ "o": -> @swapFocusAndAnchor()
"G": "forward documentboundary"
- "g": "backward documentboundary"
- "w": -> @moveByWord forward
- "W": -> @moveByWord forward
-
- "o": ->
- # Swap the anchor and focus. This is too slow if the selection is large.
- direction = @getDirection()
- length = @selection.toString().length
- @selection[if direction == forward then "collapseToEnd" else "collapseToStart"]()
- @selection.modify "extend", @opposite[direction], character for [0...length]
+ "gg": "backward documentboundary"
constructor: (options) ->
+ @movements = extend {}, @movements
+ @commands = {}
@alterMethod = options.alterMethod || "extend"
- super options
-
- @push
- _name: "movement"
+ @keyQueue = ""
+ @yankedText = ""
+ super extend options,
keypress: (event) =>
@alwaysContinueBubbling =>
unless event.metaKey or event.ctrlKey or event.altKey
- keyChar = String.fromCharCode event.charCode
- if @movements[keyChar]
- @selection = window.getSelection()
- @runCountPrefixTimes =>
- switch typeof @movements[keyChar]
- when "string"
- @runMovement @movements[keyChar]
- when "function"
- @movements[keyChar].call @
- @scrollIntoView()
+ @keyQueue += String.fromCharCode event.charCode
+ @keyQueue = @keyQueue.slice Math.max 0, @keyQueue.length - 3
+ for keyChar in (@keyQueue[i..] for i in [0...@keyQueue.length])
+ if @movements[keyChar] or @commands[keyChar]
+ @keyQueue = ""
+ if @commands[keyChar]
+ @commands[keyChar].call @
+ else if @movements[keyChar]
+ @selection = window.getSelection()
+ @runCountPrefixTimes =>
+ switch typeof @movements[keyChar]
+ when "string" then @runMovement @movements[keyChar]
+ when "function" then @movements[keyChar].call @
+ @scrollIntoView()
+ break
+
+ # Aliases.
+ @movements.B = @movements.b
+ @movements.W = @movements.w
+
+ yank: (args = {}) ->
+ @yankedText = text = window.getSelection().toString()
+ @selection.deleteFromDocument() if args.deleteFromDocument
+ @selection[if @getDirection() == backward then "collapseToEnd" else "collapseToStart"]()
+ @yankedText
+
+ yankLine: ->
+ for direction in [ forward, backward ]
+ @runMovement "#{direction} lineboundary"
+ @swapFocusAndAnchor()
+ @lastYankedLine = @yank()
# Adapted from: http://roysharon.com/blog/37.
# I have no idea how this works (smblott, 2015/1/22).
- getLeadingElement: (selection) ->
+ # The intention is to find the element containing the focus. That's the element we need to scroll into
+ # view.
+ getElementWithFocus: (selection) ->
r = t = selection.getRangeAt 0
if selection.type == "Range"
r = t.cloneRange()
- r.collapse @getDirection() == backward
+ r.collapse(@getDirection() == backward)
t = r.startContainer
t = t.childNodes[r.startOffset] if t.nodeType == 1
o = t
@@ -171,7 +182,7 @@ class Movement extends MaintainCount
t = o || t?.parentNode
t
- # Try to scroll the leading end of the selection into view.
+ # Try to scroll the focus into view.
scrollIntoView: ->
if document.activeElement and DomUtils.isEditable document.activeElement
element = document.activeElement
@@ -182,106 +193,114 @@ class Movement extends MaintainCount
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
+ # getElementWithFocus() seems to work most (but not all) of the time.
+ leadingElement = @getElementWithFocus @selection
Scroller.scrollIntoView leadingElement if leadingElement
class VisualMode extends Movement
constructor: (options = {}) ->
@selection = window.getSelection()
- type = @selection.type
-
- if type == "None"
- HUD.showForDuration "An initial selection is required for visual mode.", 2500
- return
- # Try to start with a visible selection.
- if type == "Caret" # or @selection.isCollapsed (broken if selection is in and input)
- @moveInDirection(forward) or @moveInDirection backward
+ switch @selection.type
+ when "None"
+ HUD.showForDuration "An initial selection is required for visual mode.", 2500
+ return
+ when "Caret"
+ # Try to start with a visible selection.
+ @moveInDirection(forward) or @moveInDirection backward unless options.underEditMode
defaults =
name: "visual"
badge: "V"
exitOnEscape: true
alterMethod: "extend"
+ underEditMode: false
+ super extend defaults, options
- keypress: (event) =>
- @alwaysContinueBubbling =>
- unless event.metaKey or event.ctrlKey or event.altKey
- switch String.fromCharCode event.charCode
- when "y"
- text = window.getSelection().toString()
- chrome.runtime.sendMessage
- handler: "copyToClipboard"
- data: text
- @exit()
- 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
+ extend @commands,
+ "y": @yank
+ "Y": @yankLine
- super extend defaults, options
- @debug = true
-
- # 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
- # window.removeEventListener onMouseUp
- # new VisualMode @options
- # window.addEventListener "mouseup", onMouseUp, true
-
- exit: ->
- super()
+ if @options.underEditMode
+ extend @commands,
+ "d": => @yank deleteFromDocument: true
+
+ yank: (args...) ->
+ text = super args...
unless @options.underEditMode
- if document.activeElement and DomUtils. isEditable document.activeElement
+ length = text.length
+ text = text.replace /\s+/g, " "
+ text = text[...12] + "..." if 15 < length
+ HUD.showForDuration "Yanked #{length} character#{if length == 1 then "" else "s"}: \"#{text}\".", 2500
+ @exit()
+
+ exit: (event) ->
+ super()
+ if @options.underEditMode
+ direction = @getDirection()
+ @selection[if direction == backward then "collapseToEnd" else "collapseToStart"]()
+ else
+ if document.activeElement and DomUtils.isEditable document.activeElement
document.activeElement.blur()
+ # Now we set the clipboard. No operations which maniplulate the selection should follow this.
+ console.log "yank:", @yankedText.length, @yankedText
+ chrome.runtime.sendMessage { handler: "copyToClipboard", data: @yankedText } if @yankedText
-class EditMode extends Movement
- @activeElements = []
+class EditMode extends Movement
constructor: (options = {}) ->
- defaults =
+ @element = document.activeElement
+ return unless @element and DomUtils.isEditable @element
+
+ super
name: "edit"
badge: "E"
exitOnEscape: true
alterMethod: "move"
- @debug = true
- @element = document.activeElement
- return unless @element and DomUtils.isEditable @element
- super extend defaults, options
- handlerStack.debug = true
-
- extend @movements,
- "i": => @enterInsertMode()
- "a": => @enterInsertMode()
+ extend @commands,
+ "i": @enterInsertMode
+ "a": @enterInsertMode
+ "A": => @runMovement "forward lineboundary"; @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
-
- enterInsertMode: ->
- new InsertMode
- badge: "I"
- blurOnEscape: false
+ "p": => @pasteClipboard forward
+ "P": => @pasteClipboard backward
+ "v": -> new VisualMode underEditMode: true
+ "yy": => @withRangeSelection => @yankLine()
+
+ # Aliases.
+ @commands.Y = @commands.yy
+
+ pasteClipboard: (direction) ->
+ text = Clipboard.paste @element
+ if text
+ if text == @lastYankedLine
+ text += "\n"
+ @runMovement "#{direction} lineboundary"
+ @runMovement "#{direction} character" if direction == forward
+ DomUtils.simulateTextEntry @element, text
openLine: (direction) ->
@runMovement "#{direction} lineboundary"
@enterInsertMode()
- @simulateTextEntry "\n"
+ DomUtils.simulateTextEntry @element, "\n"
@runMovement "backward character" if direction == backward
- simulateTextEntry: (text) ->
- event = document.createEvent "TextEvent"
- event.initTextEvent "textInput", true, true, null, text
- document.activeElement.dispatchEvent event
+ enterInsertMode: ->
+ new InsertMode { badge: "I", blurOnEscape: false }
+
+ withRangeSelection: (func) ->
+ @alterMethod = "extend"
+ func.call @
+ @alterMethod = "move"
+ @selection.collapseToStart()
+
+ exit: (event, target) ->
+ super()
+ if event?.type = "keydown" and KeyboardUtils.isEscape event
+ if target? and DomUtils.isDOMDescendant @element, target
+ @element.blur()
root = exports ? window
root.VisualMode = VisualMode
diff --git a/lib/clipboard.coffee b/lib/clipboard.coffee
index 2b28df70..836b57e4 100644
--- a/lib/clipboard.coffee
+++ b/lib/clipboard.coffee
@@ -15,13 +15,15 @@ Clipboard =
document.execCommand("Copy")
document.body.removeChild(textArea)
- paste: ->
+ paste: (refocusElement = null) ->
textArea = @._createTextArea()
document.body.appendChild(textArea)
textArea.focus()
document.execCommand("Paste")
value = textArea.value
document.body.removeChild(textArea)
+ # The caller wants this element refocused.
+ refocusElement.focus() if refocusElement
value
diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee
index 477abef2..c1ce051f 100644
--- a/lib/dom_utils.coffee
+++ b/lib/dom_utils.coffee
@@ -231,6 +231,11 @@ DomUtils =
@remove()
false
+ simulateTextEntry: (element, text) ->
+ event = document.createEvent "TextEvent"
+ event.initTextEvent "textInput", true, true, null, text
+ element.dispatchEvent event
+
extend DomUtils,
# From: https://github.com/component/textarea-caret-position/blob/master/index.js
getCaretCoordinates: do ->