aboutsummaryrefslogtreecommitdiffstats
path: root/content_scripts/mode_find.coffee
blob: 4c1468890048231eb4d00fde1e686c2c4b0821b3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# NOTE(smblott).  Ultimately, all of the FindMode-related code should be moved here.

# This prevents unmapped printable characters from being passed through to underlying page; see #1415.  Only
# used by PostFindMode, below.
class SuppressPrintable extends Mode
  constructor: (options) ->
    super options
    handler = (event) => if KeyboardUtils.isPrintable event then @suppressEvent else @continueBubbling
    type = document.getSelection().type

    # We use unshift here, so we see events after normal mode, so we only see unmapped keys.
    @unshift
      _name: "mode-#{@id}/suppress-printable"
      keydown: handler
      keypress: handler
      keyup: (event) =>
        # If the selection type has changed (usually, no longer "Range"), then the user is interacting with
        # the input element, so we get out of the way.  See discussion of option 5c from #1415.
        if document.getSelection().type != type then @exit() else handler event

# When we use find, the selection/focus can land in a focusable/editable element.  In this situation, special
# considerations apply.  We implement three special cases:
#   1. Disable insert mode, because the user hasn't asked to enter insert mode.  We do this by using
#      InsertMode.suppressEvent.
#   2. Prevent unmapped printable keyboard events from propagating to the page; see #1415.  We do this by
#      inheriting from SuppressPrintable.
#   3. If the very-next keystroke is Escape, then drop immediately into insert mode.
#
class PostFindMode extends SuppressPrintable
  constructor: ->
    return unless document.activeElement and DomUtils.isEditable document.activeElement
    element = document.activeElement

    super
      name: "post-find"
      # PostFindMode shares a singleton with focusInput; each displaces the other.
      singleton: "post-find-mode/focus-input"
      exitOnBlur: element
      exitOnClick: true
      keydown: (event) -> InsertMode.suppressEvent event # Always truthy, so always continues bubbling.
      keypress: (event) -> InsertMode.suppressEvent event
      keyup: (event) -> InsertMode.suppressEvent event

    # If the very-next keydown is Escape, then exit immediately, thereby passing subsequent keys to the
    # underlying insert-mode instance.
    @push
      _name: "mode-#{@id}/handle-escape"
      keydown: (event) =>
        if KeyboardUtils.isEscape event
          DomUtils.suppressKeyupAfterEscape handlerStack
          @exit()
          @suppressEvent
        else
          handlerStack.remove()
          @continueBubbling

class FindMode extends Mode
  @query:
    rawQuery: ""
    matchCount: 0
    hasResults: false

  constructor: (options = {}) ->
    # Save the selection, so findInPlace can restore it.
    @initialRange = getCurrentRange()
    FindMode.query = rawQuery: ""
    if options.returnToViewport
      @scrollX = window.scrollX
      @scrollY = window.scrollY
    super extend options,
      name: "find"
      indicator: false
      exitOnClick: true

    HUD.showFindMode this

  exit: (event) ->
    super()
    handleEscapeForFindMode() if event

  restoreSelection: ->
    range = @initialRange
    selection = getSelection()
    selection.removeAllRanges()
    selection.addRange range

  findInPlace: (query) ->
    # If requested, restore the scroll position (so that failed searches leave the scroll position unchanged).
    @checkReturnToViewPort()
    FindMode.updateQuery query
    # Restore the selection.  That way, we're always searching forward from the same place, so we find the right
    # match as the user adds matching characters, or removes previously-matched characters. See #1434.
    @restoreSelection()
    query = if FindMode.query.isRegex then FindMode.getNextQueryFromRegexMatches(0) else FindMode.query.parsedQuery
    FindMode.query.hasResults = FindMode.execute query

  @updateQuery: (query) ->
    @query.rawQuery = query
    # the query can be treated differently (e.g. as a plain string versus regex depending on the presence of
    # escape sequences. '\' is the escape character and needs to be escaped itself to be used as a normal
    # character. here we grep for the relevant escape sequences.
    @query.isRegex = Settings.get 'regexFindMode'
    hasNoIgnoreCaseFlag = false
    @query.parsedQuery = @query.rawQuery.replace /(\\{1,2})([rRI]?)/g, (match, slashes, flag) =>
      return match if flag == "" or slashes.length != 1
      switch (flag)
        when "r"
          @query.isRegex = true
        when "R"
          @query.isRegex = false
        when "I"
          hasNoIgnoreCaseFlag = true
      ""

    # default to 'smartcase' mode, unless noIgnoreCase is explicitly specified
    @query.ignoreCase = !hasNoIgnoreCaseFlag && !Utils.hasUpperCase(@query.parsedQuery)

    regexPattern = if @query.isRegex
      @query.parsedQuery
    else
      Utils.escapeRegexSpecialCharacters @query.parsedQuery

    # If we are dealing with a regex, grep for all matches in the text, and then call window.find() on them
    # sequentially so the browser handles the scrolling / text selection.
    # If we are doing a basic plain string match, we still want to grep for matches of the string, so we can
    # show a the number of results.
    try
      pattern = new RegExp regexPattern, "g#{if @query.ignoreCase then "i" else ""}"
    catch error
      return # If we catch a SyntaxError, assume the user is not done typing yet and return quietly.

    # innerText will not return the text of hidden elements, and strip out tags while preserving newlines.
    # NOTE(mrmr1993): innerText doesn't include the text contents of <input>s and <textarea>s. See #1118.
    text = document.body.innerText
    regexMatches = text.match pattern
    @query.regexMatches = regexMatches if @query.isRegex
    @query.activeRegexIndex = 0 if @query.isRegex
    @query.matchCount = regexMatches?.length

  @getNextQueryFromRegexMatches: (stepSize) ->
    # find()ing an empty query always returns false
    return "" unless @query.regexMatches

    totalMatches = @query.regexMatches.length
    @query.activeRegexIndex += stepSize + totalMatches
    @query.activeRegexIndex %= totalMatches

    @query.regexMatches[@query.activeRegexIndex]

  @getQuery: (backwards) ->
    # check if the query has been changed by a script in another frame
    mostRecentQuery = FindModeHistory.getQuery()
    if (mostRecentQuery != @query.rawQuery)
      @updateQuery mostRecentQuery

    if @query.isRegex
      @getNextQueryFromRegexMatches(if backwards then -1 else 1)
    else
      @query.parsedQuery

  @saveQuery: -> FindModeHistory.saveQuery @query.rawQuery

  # :options is an optional dict. valid parameters are 'caseSensitive' and 'backwards'.
  @execute: (query, options) ->
    result = null
    options = extend {
      backwards: false
      caseSensitive: !@query.ignoreCase
      colorSelection: true
    }, options
    query ?= FindMode.getQuery options.backwards

    if options.colorSelection
      document.body.classList.add("vimiumFindMode")
      # ignore the selectionchange event generated by find()
      document.removeEventListener("selectionchange", @restoreDefaultSelectionHighlight, true)

    result = window.find(query, options.caseSensitive, options.backwards, true, false, true, false)

    if options.colorSelection
      setTimeout(
        => document.addEventListener("selectionchange", @restoreDefaultSelectionHighlight, true)
      , 0)

    # We are either in normal mode ("n"), or find mode ("/").  We are not in insert mode.  Nevertheless, if a
    # previous find landed in an editable element, then that element may still be activated.  In this case, we
    # don't want to leave it behind (see #1412).
    if document.activeElement and DomUtils.isEditable document.activeElement
      document.activeElement.blur() unless DomUtils.isSelected document.activeElement

    result

  @restoreDefaultSelectionHighlight: -> document.body.classList.remove("vimiumFindMode")

  checkReturnToViewPort: ->
    window.scrollTo @scrollX, @scrollY if @options.returnToViewport

getCurrentRange = ->
  selection = getSelection()
  if selection.type == "None"
    range = document.createRange()
    range.setStart document.body, 0
    range.setEnd document.body, 0
    range
  else
    selection.collapseToStart() if selection.type == "Range"
    selection.getRangeAt 0

root = exports ? window
root.PostFindMode = PostFindMode
root.FindMode = FindMode