Files
atom/src/command-registry.js
2019-05-31 18:33:56 +02:00

485 lines
17 KiB
JavaScript

'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;
}