aboutsummaryrefslogtreecommitdiffstats
path: root/content_scripts/mode.coffee
blob: 9de423ff3a2916cef2189b1d68639755f622c75c (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
212
213
#
# A mode implements a number of keyboard (and possibly other) event handlers which are pushed onto the handler
# stack when the mode is activated, and popped off when it is deactivated.  The Mode class constructor takes a
# single argument "options" which can define (amongst other things):
#
# name:
#   A name for this mode.
#
# keydown:
# keypress:
# keyup:
#   Key handlers.  Optional: provide these as required.  The default is to continue bubbling all key events.
#
# Further options are described in the constructor, below.
#
# Additional handlers associated with a mode can be added by using the push method.  For example, if a mode
# responds to "focus" events, then push an additional handler:
#   @push
#     "focus": (event) => ....
# Such handlers are removed when the mode is deactivated.
#
# The following events can be handled:
#   keydown, keypress, keyup, click, focus and blur

# Debug only.
count = 0

class Mode
  # If Mode.debug is true, then we generate a trace of modes being activated and deactivated on the console.
  @debug: false
  @modes: []

  # Constants; short, readable names for the return values expected by handlerStack.bubbleEvent.
  continueBubbling: handlerStack.continueBubbling
  suppressEvent: handlerStack.suppressEvent
  passEventToPage: handlerStack.passEventToPage
  suppressPropagation: handlerStack.suppressPropagation
  restartBubbling: handlerStack.restartBubbling

  alwaysContinueBubbling: handlerStack.alwaysContinueBubbling
  alwaysSuppressPropagation: handlerStack.alwaysSuppressPropagation

  constructor: (@options = {}) ->
    @handlers = []
    @exitHandlers = []
    @modeIsActive = true
    @modeIsExiting = false
    @name = @options.name || "anonymous"

    @count = ++count
    @id = "#{@name}-#{@count}"
    @log "activate:", @id

    # If options.suppressAllKeyboardEvents is truthy, then all keyboard events are suppressed.  This avoids
    # the need for modes which suppress all keyboard events 1) to provide handlers for all of those events,
    # or 2) to worry about event suppression and event-handler return values.
    if @options.suppressAllKeyboardEvents
      for type in [ "keydown", "keypress", "keyup" ]
        do (handler = @options[type]) =>
          @options[type] = (event) => @alwaysSuppressPropagation => handler? event

    @push
      keydown: @options.keydown || null
      keypress: @options.keypress || null
      keyup: @options.keyup || null
      indicator: =>
        # Update the mode indicator.  Setting @options.indicator to a string shows a mode indicator in the
        # HUD.  Setting @options.indicator to 'false' forces no mode indicator.  If @options.indicator is
        # undefined, then the request propagates to the next mode.
        # The active indicator can also be changed with @setIndicator().
        if @options.indicator?
          if @options.indicator then HUD.show @options.indicator else HUD.hide true, false
          @passEventToPage
        else @continueBubbling

    # If @options.exitOnEscape is truthy, then the mode will exit when the escape key is pressed.
    if @options.exitOnEscape
      # Note. This handler ends up above the mode's own key handlers on the handler stack, so it takes
      # priority.
      @push
        _name: "mode-#{@id}/exitOnEscape"
        "keydown": (event) =>
          return @continueBubbling unless KeyboardUtils.isEscape event
          @exit event, event.target
          DomUtils.consumeKeyup event

    # If @options.exitOnBlur is truthy, then it should be an element.  The mode will exit when that element
    # loses the focus.
    if @options.exitOnBlur
      @push
        _name: "mode-#{@id}/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
      @push
        _name: "mode-#{@id}/exitOnClick"
        "click": (event) => @alwaysContinueBubbling => @exit event

    #If @options.exitOnFocus is truthy, then the mode will exit whenever a focusable element is activated.
    if @options.exitOnFocus
      @push
        _name: "mode-#{@id}/exitOnFocus"
        "focus": (event) => @alwaysContinueBubbling =>
          @exit event if DomUtils.isFocusable event.target

    # If @options.exitOnScroll is truthy, then the mode will exit on any scroll event.
    if @options.exitOnScroll
      @push
        _name: "mode-#{@id}/exitOnScroll"
        "scroll": (event) => @alwaysContinueBubbling => @exit event

    # Some modes are singletons: there may be at most one instance active at any time.  A mode is a singleton
    # if @options.singleton is set.  The value of @options.singleton should be the key which is intended to be
    # unique.  New instances deactivate existing instances with the same key.
    if @options.singleton
      singletons = Mode.singletons ||= {}
      key = @options.singleton
      @onExit -> delete singletons[key]
      singletons[key]?.exit()
      singletons[key] = this

    # If @options.passInitialKeyupEvents is set, then we pass initial non-printable keyup events to the page
    # or to other extensions (because the corresponding keydown events were passed).  This is used when
    # activating link hints, see #1522.
    if @options.passInitialKeyupEvents
      @push
        _name: "mode-#{@id}/passInitialKeyupEvents"
        keydown: => @alwaysContinueBubbling -> handlerStack.remove()
        keyup: (event) =>
          if KeyboardUtils.isPrintable event then @suppressPropagation else @passEventToPage

    # if @options.suppressTrailingKeyEvents is set, then  -- on exit -- we suppress all key events until a
    # subsquent (non-repeat) keydown or keypress.  In particular, the intention is to catch keyup events for
    # keys which we have handled, but which otherwise might trigger page actions (if the page is listening for
    # keyup events).
    if @options.suppressTrailingKeyEvents
      @onExit ->
        handler = (event) ->
          if event.repeat
            handlerStack.suppressEvent
          else
            keyEventSuppressor.exit()
            handlerStack.continueBubbling

        keyEventSuppressor = new Mode
          name: "suppress-trailing-key-events"
          keydown: handler
          keypress: handler
          keyup: -> handlerStack.suppressPropagation

    Mode.modes.push this
    @setIndicator()
    @logModes()
    # End of Mode constructor.

  setIndicator: (indicator = @options.indicator) ->
    @options.indicator = indicator
    Mode.setIndicator()

  @setIndicator: ->
    handlerStack.bubbleEvent "indicator"

  push: (handlers) ->
    handlers._name ||= "mode-#{@id}"
    @handlers.push handlerStack.push handlers

  unshift: (handlers) ->
    handlers._name ||= "mode-#{@id}"
    @handlers.push handlerStack.unshift handlers

  onExit: (handler) ->
    @exitHandlers.push handler

  exit: (args...) ->
    return if @modeIsExiting or not @modeIsActive
    @log "deactivate:", @id
    @modeIsExiting = true

    handler args... for handler in @exitHandlers
    handlerStack.remove handlerId for handlerId in @handlers
    Mode.modes = Mode.modes.filter (mode) => mode != this

    @modeIsActive = false
    @setIndicator()

  # Debugging routines.
  logModes: ->
    if Mode.debug
      @log "active modes (top to bottom):"
      @log " ", mode.id for mode in Mode.modes[..].reverse()

  log: (args...) ->
    console.log args... if Mode.debug

  # For tests only.
  @top: ->
    @modes[@modes.length-1]

  # For tests only.
  @reset: ->
    mode.exit() for mode in @modes
    @modes = []

class SuppressAllKeyboardEvents extends Mode
  constructor: (options = {}) ->
    defaults =
      name: "suppressAllKeyboardEvents"
      suppressAllKeyboardEvents: true
    super extend defaults, options

root = exports ? window
extend root, {Mode, SuppressAllKeyboardEvents}