{Emitter, Disposable, CompositeDisposable} = require 'event-kit' {calculateSpecificity, validateSelector} = require 'clear-cut' _ = require 'underscore-plus' {$} = require './space-pen-extensions' 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. # # 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) -> @registeredCommands = {} @selectorBasedListenersByCommandName = {} @inlineListenersByCommandName = {} @emitter = new Emitter destroy: -> for commandName of @registeredCommands window.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 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`. # * `jQuery` Present if the command was registered with the legacy # `$::command` method. 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}) eventWithTarget = Object.create event, target: value: target preventDefault: value: -> stopPropagation: value: -> stopImmediatePropagation: value: -> @handleCommandEvent(eventWithTarget) onWillDispatch: (callback) -> @emitter.on 'will-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: (originalEvent) => propagationStopped = false immediatePropagationStopped = false matched = false currentTarget = originalEvent.target syntheticEvent = Object.create originalEvent, eventPhase: value: Event.BUBBLING_PHASE currentTarget: get: -> currentTarget preventDefault: value: -> originalEvent.preventDefault() stopPropagation: value: -> originalEvent.stopPropagation() propagationStopped = true stopImmediatePropagation: value: -> originalEvent.stopImmediatePropagation() propagationStopped = true immediatePropagationStopped = true abortKeyBinding: value: -> originalEvent.abortKeyBinding?() @emitter.emit 'will-dispatch', syntheticEvent loop listeners = @inlineListenersByCommandName[originalEvent.type]?.get(currentTarget) ? [] if currentTarget.webkitMatchesSelector? selectorBasedListeners = (@selectorBasedListenersByCommandName[originalEvent.type] ? []) .filter (listener) -> currentTarget.webkitMatchesSelector(listener.selector) .sort (a, b) -> a.compare(b) listeners = listeners.concat(selectorBasedListeners) matched = true if listeners.length > 0 for listener in listeners break if immediatePropagationStopped listener.callback.call(currentTarget, syntheticEvent) break if currentTarget is window break if propagationStopped currentTarget = currentTarget.parentNode ? window matched commandRegistered: (commandName) -> unless @registeredCommands[commandName] window.addEventListener(commandName, @handleCommandEvent, true) @registeredCommands[commandName] = true class SelectorBasedListener constructor: (@selector, @callback) -> @specificity = calculateSpecificity(@selector) @sequenceNumber = SequenceCount++ compare: (other) -> other.specificity - @specificity or other.sequenceNumber - @sequenceNumber class InlineListener constructor: (@callback) ->