'use strict' const { Emitter, Disposable, CompositeDisposable } = require('event-kit') const { calculateSpecificity, validateSelector } = require('clear-cut') const _ = require('underscore-plus') let 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 () { this.handleCommandEvent = this.handleCommandEvent.bind(this) this.rootNode = null this.clear() } clear () { this.registeredCommands = {} this.selectorBasedListenersByCommandName = {} this.inlineListenersByCommandName = {} this.emitter = new Emitter() } attach (rootNode) { this.rootNode = rootNode for (const command in this.selectorBasedListenersByCommandName) { this.commandRegistered(command) } for (const command in this.inlineListenersByCommandName) { this.commandRegistered(command) } } destroy () { for (const commandName in this.registeredCommands) { this.rootNode.removeEventListener( commandName, this.handleCommandEvent, true ) } } // 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`. // * `listener` A listener which handles the event. Either a {Function} to // call when the given command is invoked on an element matching the // selector, or an {Object} with a `didDispatch` property which is such a // function. // // The function (`listener` itself if it is a function, or the `didDispatch` // method if `listener` is an object) will be called with `this` referencing // the matching DOM node and the following argument: // * `event`: A standard DOM event instance. Call `stopPropagation` or // `stopImmediatePropagation` to terminate bubbling early. // // Additionally, `listener` may have additional properties which are returned // to those who query using `atom.commands.findCommands`, as well as several // meaningful metadata properties: // * `displayName`: Overrides any generated `displayName` that would // otherwise be generated from the event name. // * `description`: Used by consumers to display detailed information about // the command. // * `hiddenInCommandPalette`: If `true`, this command will not appear in // the bundled command palette by default, but can still be shown with. // the `Command Palette: Show Hidden Commands` command. This is a good // option when you need to register large numbers of commands that don't // make sense to be executed from the command palette. Please use this // option conservatively, as it could reduce the discoverability of your // package's commands. // // ## 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, listener, throwOnInvalidSelector = true) { if (typeof commandName === 'object') { const commands = commandName throwOnInvalidSelector = listener const disposable = new CompositeDisposable() for (commandName in commands) { listener = commands[commandName] disposable.add(this.add(target, commandName, listener, throwOnInvalidSelector)) } return disposable } if (listener == null) { throw new Error('Cannot register a command with a null listener.') } // type Listener = ((e: CustomEvent) => void) | { // displayName?: string, // description?: string, // didDispatch(e: CustomEvent): void, // } if ((typeof listener !== 'function') && (typeof listener.didDispatch !== 'function')) { throw new Error('Listener must be a callback function or an object with a didDispatch method.') } if (typeof target === 'string') { if (throwOnInvalidSelector) { validateSelector(target) } return this.addSelectorBasedListener(target, commandName, listener) } else { return this.addInlineListener(target, commandName, listener) } } addSelectorBasedListener (selector, commandName, listener) { if (this.selectorBasedListenersByCommandName[commandName] == null) { this.selectorBasedListenersByCommandName[commandName] = [] } const listenersForCommand = this.selectorBasedListenersByCommandName[commandName] const selectorListener = new SelectorBasedListener(selector, commandName, listener) listenersForCommand.push(selectorListener) this.commandRegistered(commandName) return new Disposable(() => { listenersForCommand.splice(listenersForCommand.indexOf(selectorListener), 1) if (listenersForCommand.length === 0) { delete this.selectorBasedListenersByCommandName[commandName] } }) } addInlineListener (element, commandName, listener) { if (this.inlineListenersByCommandName[commandName] == null) { this.inlineListenersByCommandName[commandName] = new WeakMap() } const listenersForCommand = this.inlineListenersByCommandName[commandName] let listenersForElement = listenersForCommand.get(element) if (!listenersForElement) { listenersForElement = [] listenersForCommand.set(element, listenersForElement) } const inlineListener = new InlineListener(commandName, listener) listenersForElement.push(inlineListener) this.commandRegistered(commandName) return new Disposable(() => { listenersForElement.splice(listenersForElement.indexOf(inlineListener), 1) if (listenersForElement.length === 0) { listenersForCommand.delete(element) } }) } // 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 `CommandDescriptor` {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`. // Additional metadata may also be present in the returned descriptor: // * `description` a {String} describing the function of the command in more // detail than the title // * `tags` an {Array} of {String}s that describe keywords related to the // command // Any additional nonstandard metadata provided when the command was `add`ed // may also be present in the returned descriptor. findCommands ({ target }) { const commandNames = new Set() const commands = [] let currentTarget = target while (true) { let listeners for (const name in this.inlineListenersByCommandName) { listeners = this.inlineListenersByCommandName[name] if (listeners.has(currentTarget) && !commandNames.has(name)) { commandNames.add(name) const targetListeners = listeners.get(currentTarget) commands.push( ...targetListeners.map(listener => listener.descriptor) ) } } for (const commandName in this.selectorBasedListenersByCommandName) { listeners = this.selectorBasedListenersByCommandName[commandName] for (const listener of listeners) { if (listener.matchesTarget(currentTarget)) { if (!commandNames.has(commandName)) { commandNames.add(commandName) commands.push(listener.descriptor) } } } } if (currentTarget === window) { break } currentTarget = currentTarget.parentNode || window } return 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) { const event = new CustomEvent(commandName, { bubbles: true, detail }) Object.defineProperty(event, 'target', { value: target }) return this.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) { return this.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) { return this.emitter.on('did-dispatch', callback) } getSnapshot () { const snapshot = {} for (const commandName in this.selectorBasedListenersByCommandName) { const listeners = this.selectorBasedListenersByCommandName[commandName] snapshot[commandName] = listeners.slice() } return snapshot } restoreSnapshot (snapshot) { this.selectorBasedListenersByCommandName = {} for (const commandName in snapshot) { const listeners = snapshot[commandName] this.selectorBasedListenersByCommandName[commandName] = listeners.slice() } } handleCommandEvent (event) { let propagationStopped = false let immediatePropagationStopped = false let matched = [] let currentTarget = event.target const dispatchedEvent = new CustomEvent(event.type, { bubbles: true, detail: event.detail }) Object.defineProperty(dispatchedEvent, 'eventPhase', { value: Event.BUBBLING_PHASE }) Object.defineProperty(dispatchedEvent, 'currentTarget', { get () { return currentTarget } }) Object.defineProperty(dispatchedEvent, 'target', { value: currentTarget }) Object.defineProperty(dispatchedEvent, 'preventDefault', { value () { return 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 () { if (typeof event.abortKeyBinding === 'function') { event.abortKeyBinding() } } }) for (const key of Object.keys(event)) { if (!(key in dispatchedEvent)) { dispatchedEvent[key] = event[key] } } this.emitter.emit('will-dispatch', dispatchedEvent) while (true) { const commandInlineListeners = this.inlineListenersByCommandName[event.type] ? this.inlineListenersByCommandName[event.type].get(currentTarget) : null let listeners = commandInlineListeners || [] if (currentTarget.webkitMatchesSelector != null) { const selectorBasedListeners = (this.selectorBasedListenersByCommandName[event.type] || []) .filter(listener => listener.matchesTarget(currentTarget)) .sort((a, b) => a.compare(b)) listeners = selectorBasedListeners.concat(listeners) } // Call inline listeners first in reverse registration order, // and selector-based listeners by specificity and reverse // registration order. for (let i = listeners.length - 1; i >= 0; i--) { const listener = listeners[i] if (immediatePropagationStopped) { break } matched.push(listener.didDispatch.call(currentTarget, dispatchedEvent)) } if (currentTarget === window) { break } if (propagationStopped) { break } currentTarget = currentTarget.parentNode || window } this.emitter.emit('did-dispatch', dispatchedEvent) return (matched.length > 0 ? Promise.all(matched) : null) } commandRegistered (commandName) { if (this.rootNode != null && !this.registeredCommands[commandName]) { this.rootNode.addEventListener(commandName, this.handleCommandEvent, true) return (this.registeredCommands[commandName] = true) } } } // type Listener = { // descriptor: CommandDescriptor, // extractDidDispatch: (e: CustomEvent) => void, // }; class SelectorBasedListener { constructor (selector, commandName, listener) { this.selector = selector this.didDispatch = extractDidDispatch(listener) this.descriptor = extractDescriptor(commandName, listener) this.specificity = calculateSpecificity(this.selector) this.sequenceNumber = SequenceCount++ } compare (other) { return ( this.specificity - other.specificity || this.sequenceNumber - other.sequenceNumber ) } matchesTarget (target) { return target.webkitMatchesSelector && target.webkitMatchesSelector(this.selector) } } class InlineListener { constructor (commandName, listener) { this.didDispatch = extractDidDispatch(listener) this.descriptor = extractDescriptor(commandName, listener) } } // type CommandDescriptor = { // name: string, // displayName: string, // }; function extractDescriptor (name, listener) { return Object.assign( _.omit(listener, 'didDispatch'), { name, displayName: listener.displayName ? listener.displayName : _.humanizeEventName(name) } ) } function extractDidDispatch (listener) { return typeof listener === 'function' ? listener : listener.didDispatch }