]> git.gir.st - VimFx.git/blob - extension/lib/selection.coffee
Fix visual mode in Firefox 70
[VimFx.git] / extension / lib / selection.coffee
1 # This file helps dealing with text selection: Querying it, modifying it and
2 # moving its caret.
3
4 FORWARD = true
5 BACKWARD = false
6
7 class SelectionManager
8 constructor: (@window) ->
9 @selection = @window.getSelection()
10 @nsISelectionController = @window
11 .getInterface(Ci.nsIWebNavigation)
12 .QueryInterface(Ci.nsIInterfaceRequestor)
13 .getInterface(Ci.nsISelectionDisplay)
14 .QueryInterface(Ci.nsISelectionController)
15
16 @FORWARD = FORWARD
17 @BACKWARD = BACKWARD
18
19 enableCaret: ->
20 @nsISelectionController.setCaretEnabled(true)
21 @nsISelectionController.setCaretReadOnly(false)
22 @nsISelectionController.setCaretVisibilityDuringSelection(true)
23
24 collapse: ->
25 return if @selection.isCollapsed
26 direction = @getDirection()
27 if direction == FORWARD
28 @selection.collapseToEnd()
29 else
30 @selection.collapseToStart()
31
32 moveCaretThrowing: (method, direction, select = true) ->
33 @nsISelectionController[method](direction, select)
34
35 moveCaret: (args...) ->
36 try
37 @moveCaretThrowing(args...)
38 catch error
39 return error
40 return null
41
42 # The simplest way to measure selection length is
43 # `selection.toString().length`. However, `selection.toString()` collapses
44 # whitespace even in `<pre>` elements, so it is sadly not reliable. Instead we
45 # have to measure client rects.
46 getSelectionLength: ->
47 width = 0
48 numRects = 0
49 for index in [0...@selection.rangeCount] by 1
50 for rect in @selection.getRangeAt(index).getClientRects()
51 width += rect.width
52 numRects += 1
53 return [width, numRects]
54
55 getDirection: (directionIfCollapsed = FORWARD) ->
56 # The “test for newlines” trick used in `@reverseDirection` should _not_ be
57 # used here. If it were, selecting the newline(s) between two paragraphs and
58 # then `@collapse()`ing that selection might move the caret.
59 return directionIfCollapsed if @selection.isCollapsed
60
61 # Creating backwards ranges is not supported. When trying to do so,
62 # `range.toString()` returns the empty string.
63 range = @window.document.createRange()
64 range.setStart(@selection.anchorNode, @selection.anchorOffset)
65 range.setEnd(@selection.focusNode, @selection.focusOffset)
66 return range.toString() != ''
67
68 reverseDirection: ->
69 # If the caret is at the end of a paragraph, or at the start of the
70 # paragraph, and the newline(s) between those paragraphs happen to be
71 # selected, it _looks_ as if `selection.isCollapsed` should be `true`, but
72 # it isn't because of said (virtual) newline characters. If so, the below
73 # algorithm might move the caret from the start of a paragraph to the end of
74 # the previous paragraph, etc. So don’t do anything if the selection is
75 # empty or newlines only.
76 return if /^\n*$/.test(@selection.toString())
77
78 direction = @getDirection()
79
80 range = @selection.getRangeAt(0)
81 edge = if direction == FORWARD then 'start' else 'end'
82 {"#{edge}Container": edgeElement, "#{edge}Offset": edgeOffset} = range
83 range.collapse(not direction)
84 @selection.removeAllRanges()
85 @selection.addRange(range)
86 @selection.extend(edgeElement, edgeOffset)
87
88 # When going from backward to forward the caret might end up at the line
89 # _after_ the selection if the selection ends at the end of a line, which
90 # looks a bit odd. This adjusts that case.
91 if direction == BACKWARD
92 [oldWidth] = @getSelectionLength()
93 @moveCaret('characterMove', BACKWARD)
94 [newWidth] = @getSelectionLength()
95 unless newWidth == oldWidth
96 @moveCaret('characterMove', FORWARD)
97
98 wordMoveAdjusted: (direction, select = true) ->
99 selectionDirection = @getDirection(direction)
100
101 try
102 if (select and selectionDirection != direction) or
103 (not select and direction == FORWARD)
104 @_wordMoveAdjusted(direction)
105 else
106 @moveCaretThrowing('wordMove', direction, select)
107 catch error
108 throw error unless error.name == 'NS_ERROR_FAILURE'
109 @collapse() unless select
110 return error
111
112 unless select
113 # When at the very end of the document `@_wordMoveAdjusted(FORWARD)` might
114 # end up moving the caret _backward!_ If so, move the caret back.
115 @moveCaret('wordMove', direction) if @getDirection(direction) != direction
116 @collapse()
117
118 return null
119
120 _wordMoveAdjusted: (direction) ->
121 [oldWidth, oldNumRects] = @getSelectionLength()
122
123 # Do the old “two steps forward and one step back” trick to avoid the
124 # selection ending with whitespace. (Vice versa for backwards selections.)
125 @moveCaretThrowing('wordMove', direction)
126 @moveCaretThrowing('wordMove', direction)
127 @moveCaretThrowing('wordMove', not direction)
128
129 [newWidth, newNumRects] = @getSelectionLength()
130
131 # However, in some cases the above can result in the caret not moving at
132 # all. If so, go _three_ steps forward and one back. (Again, vice versa for
133 # backwards selections.)
134 if oldNumRects == newNumRects and oldWidth == newWidth
135 @moveCaretThrowing('wordMove', direction)
136 @moveCaretThrowing('wordMove', direction)
137 @moveCaretThrowing('wordMove', direction)
138 @moveCaretThrowing('wordMove', not direction)
139
140 [newWidth, newNumRects] = @getSelectionLength()
141
142 # Finally, if everything else failed to move the caret (such as when being
143 # one word from the end of the document), simply move one step.
144 if oldNumRects == newNumRects and oldWidth == newWidth
145 @moveCaretThrowing('wordMove', direction)
146
147 module.exports = SelectionManager
Imprint / Impressum