aboutsummaryrefslogtreecommitdiffstats
path: root/content_scripts/mode_key_handler.coffee
blob: c79f1991971cb6c99a834527fab17e060bccacb0 (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
# The important data structure here is the "keyState".  The key state is a non-empty list of objects, the keys
# of which are key names, and the values are other key-mapping objects or commands (strings).  Key-mapping
# objects can be arbitrarily nested; so we support any length of multi-key mapping.
#
# Whenever we consume a key, we append a new copy of the global key mapping to the key state (hence, the
# global mappings are always available, and the key state is always non-empty).

class KeyHandlerMode extends Mode
  useCount: true
  countPrefix: 0
  keydownEvents: {}
  keyState: []

  constructor: (options) ->
    # A function accepting a command name and a count; required.
    @commandHandler = options.commandHandler ? (->)
    # A Key mapping structure; required.
    @keyMapping = options.keyMapping ? {}
    @useCount = false if options.noCount
    @reset()

    # We don't pass these options on to super().
    options = Utils.copyObjectOmittingProperties options, "commandHandler", "keyMapping", "noCount"

    super extend options,
      keydown: @onKeydown.bind this
      keypress: @onKeypress.bind this
      keyup: @onKeyup.bind this
      # We cannot track matching keydown/keyup events if we lose the focus.
      blur: (event) => @alwaysContinueBubbling =>
        @keydownEvents = {} if event.target == window

  onKeydown: (event) ->
    keyChar = KeyboardUtils.getKeyCharString event

    if KeyboardUtils.isEscape event
      if @isInResetState()
        @continueBubbling
      else
        @reset()
        DomUtils.suppressKeyupAfterEscape handlerStack
        false # Suppress event.

    else if keyChar and @keyCharIsKeyStatePrefix keyChar
        @advanceKeyState keyChar
        commands = @keyState.filter (entry) -> "string" == typeof entry
        @invokeCommand commands[0] if 0 < commands.length
        false # Suppress event.

    else
      # We did not handle the event, but we might handle the subsequent keypress event.  If we *will* be
      # handling that event, then we need to suppress propagation of this keydown event to prevent triggering
      # page features like Google instant search.
      keyChar = KeyboardUtils.getKeyChar event
      if keyChar and (@keyCharIsKeyStatePrefix(keyChar) or @isCountKey keyChar)
        DomUtils.suppressPropagation event
        @keydownEvents[@getEventCode event] = true
        @stopBubblingAndTrue
      else
        @countPrefix = 0 if keyChar
        @continueBubbling

  onKeypress: (event) ->
    keyChar = KeyboardUtils.getKeyCharString event
    if keyChar and @keyCharIsKeyStatePrefix keyChar
      @advanceKeyState keyChar
      commands = @keyState.filter (entry) -> "string" == typeof entry
      @invokeCommand commands[0] if 0 < commands.length
      false # Suppress event.
    else if keyChar and @isCountKey keyChar
      @countPrefix = @countPrefix * 10 + parseInt keyChar
      false # Suppress event.
    else
      @continueBubbling

  onKeyup: (event) ->
    eventCode = @getEventCode event
    if eventCode of @keydownEvents
      delete @keydownEvents[eventCode]
      DomUtils.suppressPropagation event
      @stopBubblingAndTrue
    else
      @continueBubbling

  # This tests whether keyChar is a prefix of any current mapping in the key state.
  keyCharIsKeyStatePrefix: (keyChar) ->
    for mapping in @keyState
      return true if keyChar of mapping
    false

  # This is called whenever a keyChar is matched.  We keep any existing entries matching keyChar, and append a
  # new copy of the global key mappings.
  advanceKeyState: (keyChar) ->
    newKeyState =
      for mapping in @keyState
        continue unless keyChar of mapping
        mapping[keyChar]
    @keyState = [newKeyState..., @keyMapping]

  # This is called to invoke a command and reset the key state.
  invokeCommand: (command) ->
    countPrefix = if 0 < @countPrefix then @countPrefix else 1
    @reset()
    @commandHandler command, countPrefix

  # Reset the state (as if no keys had been handled).
  reset: ->
    @countPrefix = 0
    @keyState = [@keyMapping]

  # This tests whether we are in the reset state.  It is used to check whether we should be using escape to
  # reset the key state, or passing it to the page.
  isInResetState: ->
    @countPrefix == 0 and @keyState.length == 1

  # This tests whether keyChar should be treated as a count key.
  isCountKey: (keyChar) ->
    return false unless @useCount and keyChar.length == 1
    if 0 < @countPrefix
      '0' <= keyChar <= '9'
    else
      '1' <= keyChar <= '9'

  getEventCode: (event) -> event.keyCode

# Demo/test code.
# A (very) poor-man's normal mode.

demoKeyMapping =
  j: "scrollDown"
  k: "scrollUp"
  i: "enterInsertMode"
  g:
    g: "scrollToTop"
    a: "scrollToTop"
    z: "scrollToBottom"
    i: "focusInput"
  # A three-key binding.
  a:
    b:
      c: "enterInsertMode"
      # And this should override "j" on its own.
      j: "enterInsertMode"

demoCommandHandler = (command, count) ->
  switch command
    when "scrollDown" then scrollDown()
    when "scrollUp" then scrollUp()
    when "scrollToTop" then scrollToTop count
    when "scrollToBottom" then scrollToBottom()
    when "enterInsertMode" then enterInsertMode()
    when "focusInput" then focusInput count

root = exports ? window
root.KeyHandlerMode = KeyHandlerMode
root.demoKeyMapping = demoKeyMapping
root.demoCommandHandler = demoCommandHandler