forked from atom/atom
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcommand-registry.coffee
280 lines (242 loc) · 11.2 KB
/
command-registry.coffee
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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
{Emitter, Disposable, CompositeDisposable} = require 'event-kit'
{calculateSpecificity, validateSelector} = require 'clear-cut'
_ = require 'underscore-plus'
SequenceCount = 0
# Public: Associates listener functions with commands in a
# context-sensitive way using CSS selectors. You can access a global instance of
# this class via `atom.commands`, and commands registered there will be
# presented in the command palette.
#
# The global command registry facilitates a style of event handling known as
# *event delegation* that was popularized by jQuery. Atom commands are expressed
# as custom DOM events that can be invoked on the currently focused element via
# a key binding or manually via the command palette. Rather than binding
# listeners for command events directly to DOM nodes, you instead register
# command event listeners globally on `atom.commands` and constrain them to
# specific kinds of elements with CSS selectors.
#
# Command names must follow the `namespace:action` pattern, where `namespace`
# will typically be the name of your package, and `action` describes the
# behavior of your command. If either part consists of multiple words, these
# must be separated by hyphens. E.g. `awesome-package:turn-it-up-to-eleven`.
# All words should be lowercased.
#
# As the event bubbles upward through the DOM, all registered event listeners
# with matching selectors are invoked in order of specificity. In the event of a
# specificity tie, the most recently registered listener is invoked first. This
# mirrors the "cascade" semantics of CSS. Event listeners are invoked in the
# context of the current DOM node, meaning `this` always points at
# `event.currentTarget`. As is normally the case with DOM events,
# `stopPropagation` and `stopImmediatePropagation` can be used to terminate the
# bubbling process and prevent invocation of additional listeners.
#
# ## Example
#
# Here is a command that inserts the current date in an editor:
#
# ```coffee
# atom.commands.add 'atom-text-editor',
# 'user:insert-date': (event) ->
# editor = @getModel()
# editor.insertText(new Date().toLocaleString())
# ```
module.exports =
class CommandRegistry
constructor: ->
@rootNode = null
@clear()
clear: ->
@registeredCommands = {}
@selectorBasedListenersByCommandName = {}
@inlineListenersByCommandName = {}
@emitter = new Emitter
attach: (@rootNode) ->
@commandRegistered(command) for command of @selectorBasedListenersByCommandName
@commandRegistered(command) for command of @inlineListenersByCommandName
destroy: ->
for commandName of @registeredCommands
@rootNode.removeEventListener(commandName, @handleCommandEvent, true)
return
# Public: Add one or more command listeners associated with a selector.
#
# ## Arguments: Registering One Command
#
# * `target` A {String} containing a CSS selector or a DOM element. If you
# pass a selector, the command will be globally associated with all matching
# elements. The `,` combinator is not currently supported. If you pass a
# DOM element, the command will be associated with just that element.
# * `commandName` A {String} containing the name of a command you want to
# handle such as `user:insert-date`.
# * `callback` A {Function} to call when the given command is invoked on an
# element matching the selector. It will be called with `this` referencing
# the matching DOM node.
# * `event` A standard DOM event instance. Call `stopPropagation` or
# `stopImmediatePropagation` to terminate bubbling early.
#
# ## Arguments: Registering Multiple Commands
#
# * `target` A {String} containing a CSS selector or a DOM element. If you
# pass a selector, the commands will be globally associated with all
# matching elements. The `,` combinator is not currently supported.
# If you pass a DOM element, the command will be associated with just that
# element.
# * `commands` An {Object} mapping command names like `user:insert-date` to
# listener {Function}s.
#
# Returns a {Disposable} on which `.dispose()` can be called to remove the
# added command handler(s).
add: (target, commandName, callback) ->
if typeof commandName is 'object'
commands = commandName
disposable = new CompositeDisposable
for commandName, callback of commands
disposable.add @add(target, commandName, callback)
return disposable
if typeof callback isnt 'function'
throw new Error("Can't register a command with non-function callback.")
if typeof target is 'string'
validateSelector(target)
@addSelectorBasedListener(target, commandName, callback)
else
@addInlineListener(target, commandName, callback)
addSelectorBasedListener: (selector, commandName, callback) ->
@selectorBasedListenersByCommandName[commandName] ?= []
listenersForCommand = @selectorBasedListenersByCommandName[commandName]
listener = new SelectorBasedListener(selector, callback)
listenersForCommand.push(listener)
@commandRegistered(commandName)
new Disposable =>
listenersForCommand.splice(listenersForCommand.indexOf(listener), 1)
delete @selectorBasedListenersByCommandName[commandName] if listenersForCommand.length is 0
addInlineListener: (element, commandName, callback) ->
@inlineListenersByCommandName[commandName] ?= new WeakMap
listenersForCommand = @inlineListenersByCommandName[commandName]
unless listenersForElement = listenersForCommand.get(element)
listenersForElement = []
listenersForCommand.set(element, listenersForElement)
listener = new InlineListener(callback)
listenersForElement.push(listener)
@commandRegistered(commandName)
new Disposable ->
listenersForElement.splice(listenersForElement.indexOf(listener), 1)
listenersForCommand.delete(element) if listenersForElement.length is 0
# Public: Find all registered commands matching a query.
#
# * `params` An {Object} containing one or more of the following keys:
# * `target` A DOM node that is the hypothetical target of a given command.
#
# Returns an {Array} of {Object}s containing the following keys:
# * `name` The name of the command. For example, `user:insert-date`.
# * `displayName` The display name of the command. For example,
# `User: Insert Date`.
findCommands: ({target}) ->
commandNames = new Set
commands = []
currentTarget = target
loop
for name, listeners of @inlineListenersByCommandName
if listeners.has(currentTarget) and not commandNames.has(name)
commandNames.add(name)
commands.push({name, displayName: _.humanizeEventName(name)})
for commandName, listeners of @selectorBasedListenersByCommandName
for listener in listeners
if currentTarget.webkitMatchesSelector?(listener.selector)
unless commandNames.has(commandName)
commandNames.add(commandName)
commands.push
name: commandName
displayName: _.humanizeEventName(commandName)
break if currentTarget is window
currentTarget = currentTarget.parentNode ? window
commands
# Public: Simulate the dispatch of a command on a DOM node.
#
# This can be useful for testing when you want to simulate the invocation of a
# command on a detached DOM node. Otherwise, the DOM node in question needs to
# be attached to the document so the event bubbles up to the root node to be
# processed.
#
# * `target` The DOM node at which to start bubbling the command event.
# * `commandName` {String} indicating the name of the command to dispatch.
dispatch: (target, commandName, detail) ->
event = new CustomEvent(commandName, {bubbles: true, detail})
Object.defineProperty(event, 'target', value: target)
@handleCommandEvent(event)
# Public: Invoke the given callback before dispatching a command event.
#
# * `callback` {Function} to be called before dispatching each command
# * `event` The Event that will be dispatched
onWillDispatch: (callback) ->
@emitter.on 'will-dispatch', callback
# Public: Invoke the given callback after dispatching a command event.
#
# * `callback` {Function} to be called after dispatching each command
# * `event` The Event that was dispatched
onDidDispatch: (callback) ->
@emitter.on 'did-dispatch', callback
getSnapshot: ->
snapshot = {}
for commandName, listeners of @selectorBasedListenersByCommandName
snapshot[commandName] = listeners.slice()
snapshot
restoreSnapshot: (snapshot) ->
@selectorBasedListenersByCommandName = {}
for commandName, listeners of snapshot
@selectorBasedListenersByCommandName[commandName] = listeners.slice()
return
handleCommandEvent: (event) =>
propagationStopped = false
immediatePropagationStopped = false
matched = false
currentTarget = event.target
dispatchedEvent = new CustomEvent(event.type, {bubbles: true, detail: event.detail})
Object.defineProperty dispatchedEvent, 'eventPhase', value: Event.BUBBLING_PHASE
Object.defineProperty dispatchedEvent, 'currentTarget', get: -> currentTarget
Object.defineProperty dispatchedEvent, 'target', value: currentTarget
Object.defineProperty dispatchedEvent, 'preventDefault', value: ->
event.preventDefault()
Object.defineProperty dispatchedEvent, 'stopPropagation', value: ->
event.stopPropagation()
propagationStopped = true
Object.defineProperty dispatchedEvent, 'stopImmediatePropagation', value: ->
event.stopImmediatePropagation()
propagationStopped = true
immediatePropagationStopped = true
Object.defineProperty dispatchedEvent, 'abortKeyBinding', value: ->
event.abortKeyBinding?()
for key in Object.keys(event)
dispatchedEvent[key] = event[key]
@emitter.emit 'will-dispatch', dispatchedEvent
loop
listeners = @inlineListenersByCommandName[event.type]?.get(currentTarget) ? []
if currentTarget.webkitMatchesSelector?
selectorBasedListeners =
(@selectorBasedListenersByCommandName[event.type] ? [])
.filter (listener) -> currentTarget.webkitMatchesSelector(listener.selector)
.sort (a, b) -> a.compare(b)
listeners = selectorBasedListeners.concat(listeners)
matched = true if listeners.length > 0
# Call inline listeners first in reverse registration order,
# and selector-based listeners by specificity and reverse
# registration order.
for listener in listeners by -1
break if immediatePropagationStopped
listener.callback.call(currentTarget, dispatchedEvent)
break if currentTarget is window
break if propagationStopped
currentTarget = currentTarget.parentNode ? window
@emitter.emit 'did-dispatch', dispatchedEvent
matched
commandRegistered: (commandName) ->
if @rootNode? and not @registeredCommands[commandName]
@rootNode.addEventListener(commandName, @handleCommandEvent, true)
@registeredCommands[commandName] = true
class SelectorBasedListener
constructor: (@selector, @callback) ->
@specificity = calculateSpecificity(@selector)
@sequenceNumber = SequenceCount++
compare: (other) ->
@specificity - other.specificity or
@sequenceNumber - other.sequenceNumber
class InlineListener
constructor: (@callback) ->