mirror of
https://github.com/atom/atom.git
synced 2026-01-23 22:08:08 -05:00
458 lines
16 KiB
JavaScript
458 lines
16 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
|
|
}
|