mirror of
https://github.com/pulsar-edit/pulsar.git
synced 2024-09-20 15:37:46 +03:00
251 lines
9.8 KiB
CoffeeScript
251 lines
9.8 KiB
CoffeeScript
{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) ->
|