aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorStephen Blott2015-02-09 11:40:53 +0000
committerStephen Blott2015-02-09 11:40:53 +0000
commit0bf605a934115083e700f5de090f39841417482a (patch)
tree408c191bd702da6847bca9943f6eba93ad28b209
parentac648a0e9f53c2fc359daa68309c25dd8c9db031 (diff)
parented306994697f6f9f5e13f9d018b5c7ffa2fff680 (diff)
downloadvimium-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.coffee6
-rw-r--r--background_scripts/main.coffee5
-rw-r--r--content_scripts/mode.coffee19
-rw-r--r--content_scripts/mode_find.coffee3
-rw-r--r--content_scripts/mode_insert.coffee11
-rw-r--r--content_scripts/mode_visual.coffee20
-rw-r--r--content_scripts/mode_visual_edit.coffee803
-rw-r--r--content_scripts/scroller.coffee55
-rw-r--r--content_scripts/vimium_frontend.coffee48
-rw-r--r--lib/dom_utils.coffee64
-rw-r--r--lib/handler_stack.coffee15
-rw-r--r--lib/keyboard_utils.coffee9
-rw-r--r--lib/utils.coffee17
-rw-r--r--manifest.json2
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"],