Merge pull request #15394 from atom/fb-wb-command-registry-js

Convert CommandRegistry to JavaScript
This commit is contained in:
Nathan Sobo
2017-08-21 09:35:31 -06:00
committed by GitHub
4 changed files with 751 additions and 583 deletions

View File

@@ -1,281 +0,0 @@
{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, throwOnInvalidSelector = true) ->
if typeof commandName is 'object'
commands = commandName
throwOnInvalidSelector = callback
disposable = new CompositeDisposable
for commandName, callback of commands
disposable.add @add(target, commandName, callback, throwOnInvalidSelector)
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) if throwOnInvalidSelector
@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) ->

406
src/command-registry.js Normal file
View File

@@ -0,0 +1,406 @@
'use strict'
/* global Event, CustomEvent */
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`.
// * `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, throwOnInvalidSelector = true) {
if (typeof commandName === 'object') {
const commands = commandName
throwOnInvalidSelector = callback
const disposable = new CompositeDisposable()
for (commandName in commands) {
callback = commands[commandName]
disposable.add(
this.add(target, commandName, callback, throwOnInvalidSelector)
)
}
return disposable
}
if (typeof callback !== 'function') {
throw new Error("Can't register a command with non-function callback.")
}
if (typeof target === 'string') {
if (throwOnInvalidSelector) {
validateSelector(target)
}
return this.addSelectorBasedListener(target, commandName, callback)
} else {
return this.addInlineListener(target, commandName, callback)
}
}
addSelectorBasedListener (selector, commandName, callback) {
if (this.selectorBasedListenersByCommandName[commandName] == null) {
this.selectorBasedListenersByCommandName[commandName] = []
}
const listenersForCommand = this.selectorBasedListenersByCommandName[commandName]
const listener = new SelectorBasedListener(selector, callback)
listenersForCommand.push(listener)
this.commandRegistered(commandName)
return new Disposable(() => {
listenersForCommand.splice(listenersForCommand.indexOf(listener), 1)
if (listenersForCommand.length === 0) {
return delete this.selectorBasedListenersByCommandName[commandName]
}
})
}
addInlineListener (element, commandName, callback) {
if (this.inlineListenersByCommandName[commandName] == null) {
this.inlineListenersByCommandName[commandName] = new WeakMap()
}
const listenersForCommand = this.inlineListenersByCommandName[commandName]
let listenersForElement = listenersForCommand.get(element)
if (listenersForElement == null) {
listenersForElement = []
listenersForCommand.set(element, listenersForElement)
}
const listener = new InlineListener(callback)
listenersForElement.push(listener)
this.commandRegistered(commandName)
return new Disposable(function () {
listenersForElement.splice(listenersForElement.indexOf(listener), 1)
if (listenersForElement.length === 0) {
return 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 {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 }) {
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)
commands.push({ name, displayName: _.humanizeEventName(name) })
}
}
for (const commandName in this.selectorBasedListenersByCommandName) {
listeners = this.selectorBasedListenersByCommandName[commandName]
for (const listener of listeners) {
if (
currentTarget.webkitMatchesSelector &&
currentTarget.webkitMatchesSelector(listener.selector)
) {
if (!commandNames.has(commandName)) {
commandNames.add(commandName)
commands.push({
name: commandName,
displayName: _.humanizeEventName(commandName)
})
}
}
}
}
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 = false
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 =>
currentTarget.webkitMatchesSelector(listener.selector)
)
.sort((a, b) => a.compare(b))
listeners = selectorBasedListeners.concat(listeners)
}
if (listeners.length > 0) {
matched = true
}
// 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
}
listener.callback.call(currentTarget, dispatchedEvent)
}
if (currentTarget === window) {
break
}
if (propagationStopped) {
break
}
currentTarget = currentTarget.parentNode || window
}
this.emitter.emit('did-dispatch', dispatchedEvent)
return matched
}
commandRegistered (commandName) {
if (this.rootNode != null && !this.registeredCommands[commandName]) {
this.rootNode.addEventListener(commandName, this.handleCommandEvent, true)
return (this.registeredCommands[commandName] = true)
}
}
}
class SelectorBasedListener {
constructor (selector, callback) {
this.selector = selector
this.callback = callback
this.specificity = calculateSpecificity(this.selector)
this.sequenceNumber = SequenceCount++
}
compare (other) {
return (
this.specificity - other.specificity ||
this.sequenceNumber - other.sequenceNumber
)
}
}
class InlineListener {
constructor (callback) {
this.callback = callback
}
}