aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--background_scripts/commands.coffee3
-rw-r--r--content_scripts/mode_insert.coffee6
-rw-r--r--content_scripts/mode_visual_edit.coffee136
-rw-r--r--content_scripts/scroller.coffee13
-rw-r--r--content_scripts/vimium_frontend.coffee3
-rw-r--r--lib/dom_utils.coffee94
-rw-r--r--lib/handler_stack.coffee12
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()