mirror of
https://github.com/atom/atom.git
synced 2026-04-28 03:01:47 -04:00
Merge pull request #15394 from atom/fb-wb-command-registry-js
Convert CommandRegistry to JavaScript
This commit is contained in:
@@ -1,302 +0,0 @@
|
||||
CommandRegistry = require '../src/command-registry'
|
||||
_ = require 'underscore-plus'
|
||||
|
||||
describe "CommandRegistry", ->
|
||||
[registry, parent, child, grandchild] = []
|
||||
|
||||
beforeEach ->
|
||||
parent = document.createElement("div")
|
||||
child = document.createElement("div")
|
||||
grandchild = document.createElement("div")
|
||||
parent.classList.add('parent')
|
||||
child.classList.add('child')
|
||||
grandchild.classList.add('grandchild')
|
||||
child.appendChild(grandchild)
|
||||
parent.appendChild(child)
|
||||
document.querySelector('#jasmine-content').appendChild(parent)
|
||||
|
||||
registry = new CommandRegistry
|
||||
registry.attach(parent)
|
||||
|
||||
afterEach ->
|
||||
registry.destroy()
|
||||
|
||||
describe "when a command event is dispatched on an element", ->
|
||||
it "invokes callbacks with selectors matching the target", ->
|
||||
called = false
|
||||
registry.add '.grandchild', 'command', (event) ->
|
||||
expect(this).toBe grandchild
|
||||
expect(event.type).toBe 'command'
|
||||
expect(event.eventPhase).toBe Event.BUBBLING_PHASE
|
||||
expect(event.target).toBe grandchild
|
||||
expect(event.currentTarget).toBe grandchild
|
||||
called = true
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command', bubbles: true))
|
||||
expect(called).toBe true
|
||||
|
||||
it "invokes callbacks with selectors matching ancestors of the target", ->
|
||||
calls = []
|
||||
|
||||
registry.add '.child', 'command', (event) ->
|
||||
expect(this).toBe child
|
||||
expect(event.target).toBe grandchild
|
||||
expect(event.currentTarget).toBe child
|
||||
calls.push('child')
|
||||
|
||||
registry.add '.parent', 'command', (event) ->
|
||||
expect(this).toBe parent
|
||||
expect(event.target).toBe grandchild
|
||||
expect(event.currentTarget).toBe parent
|
||||
calls.push('parent')
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command', bubbles: true))
|
||||
expect(calls).toEqual ['child', 'parent']
|
||||
|
||||
it "invokes inline listeners prior to listeners applied via selectors", ->
|
||||
calls = []
|
||||
registry.add '.grandchild', 'command', -> calls.push('grandchild')
|
||||
registry.add child, 'command', -> calls.push('child-inline')
|
||||
registry.add '.child', 'command', -> calls.push('child')
|
||||
registry.add '.parent', 'command', -> calls.push('parent')
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command', bubbles: true))
|
||||
expect(calls).toEqual ['grandchild', 'child-inline', 'child', 'parent']
|
||||
|
||||
it "orders multiple matching listeners for an element by selector specificity", ->
|
||||
child.classList.add('foo', 'bar')
|
||||
calls = []
|
||||
|
||||
registry.add '.foo.bar', 'command', -> calls.push('.foo.bar')
|
||||
registry.add '.foo', 'command', -> calls.push('.foo')
|
||||
registry.add '.bar', 'command', -> calls.push('.bar') # specificity ties favor commands added later, like CSS
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command', bubbles: true))
|
||||
expect(calls).toEqual ['.foo.bar', '.bar', '.foo']
|
||||
|
||||
it "orders inline listeners by reverse registration order", ->
|
||||
calls = []
|
||||
registry.add child, 'command', -> calls.push('child1')
|
||||
registry.add child, 'command', -> calls.push('child2')
|
||||
child.dispatchEvent(new CustomEvent('command', bubbles: true))
|
||||
expect(calls).toEqual ['child2', 'child1']
|
||||
|
||||
it "stops bubbling through ancestors when .stopPropagation() is called on the event", ->
|
||||
calls = []
|
||||
|
||||
registry.add '.parent', 'command', -> calls.push('parent')
|
||||
registry.add '.child', 'command', -> calls.push('child-2')
|
||||
registry.add '.child', 'command', (event) -> calls.push('child-1'); event.stopPropagation()
|
||||
|
||||
dispatchedEvent = new CustomEvent('command', bubbles: true)
|
||||
spyOn(dispatchedEvent, 'stopPropagation')
|
||||
grandchild.dispatchEvent(dispatchedEvent)
|
||||
expect(calls).toEqual ['child-1', 'child-2']
|
||||
expect(dispatchedEvent.stopPropagation).toHaveBeenCalled()
|
||||
|
||||
it "stops invoking callbacks when .stopImmediatePropagation() is called on the event", ->
|
||||
calls = []
|
||||
|
||||
registry.add '.parent', 'command', -> calls.push('parent')
|
||||
registry.add '.child', 'command', -> calls.push('child-2')
|
||||
registry.add '.child', 'command', (event) -> calls.push('child-1'); event.stopImmediatePropagation()
|
||||
|
||||
dispatchedEvent = new CustomEvent('command', bubbles: true)
|
||||
spyOn(dispatchedEvent, 'stopImmediatePropagation')
|
||||
grandchild.dispatchEvent(dispatchedEvent)
|
||||
expect(calls).toEqual ['child-1']
|
||||
expect(dispatchedEvent.stopImmediatePropagation).toHaveBeenCalled()
|
||||
|
||||
it "forwards .preventDefault() calls from the synthetic event to the original", ->
|
||||
registry.add '.child', 'command', (event) -> event.preventDefault()
|
||||
|
||||
dispatchedEvent = new CustomEvent('command', bubbles: true)
|
||||
spyOn(dispatchedEvent, 'preventDefault')
|
||||
grandchild.dispatchEvent(dispatchedEvent)
|
||||
expect(dispatchedEvent.preventDefault).toHaveBeenCalled()
|
||||
|
||||
it "forwards .abortKeyBinding() calls from the synthetic event to the original", ->
|
||||
registry.add '.child', 'command', (event) -> event.abortKeyBinding()
|
||||
|
||||
dispatchedEvent = new CustomEvent('command', bubbles: true)
|
||||
dispatchedEvent.abortKeyBinding = jasmine.createSpy('abortKeyBinding')
|
||||
grandchild.dispatchEvent(dispatchedEvent)
|
||||
expect(dispatchedEvent.abortKeyBinding).toHaveBeenCalled()
|
||||
|
||||
it "copies non-standard properties from the original event to the synthetic event", ->
|
||||
syntheticEvent = null
|
||||
registry.add '.child', 'command', (event) -> syntheticEvent = event
|
||||
|
||||
dispatchedEvent = new CustomEvent('command', bubbles: true)
|
||||
dispatchedEvent.nonStandardProperty = 'testing'
|
||||
grandchild.dispatchEvent(dispatchedEvent)
|
||||
expect(syntheticEvent.nonStandardProperty).toBe 'testing'
|
||||
|
||||
it "allows listeners to be removed via a disposable returned by ::add", ->
|
||||
calls = []
|
||||
|
||||
disposable1 = registry.add '.parent', 'command', -> calls.push('parent')
|
||||
disposable2 = registry.add '.child', 'command', -> calls.push('child')
|
||||
|
||||
disposable1.dispose()
|
||||
grandchild.dispatchEvent(new CustomEvent('command', bubbles: true))
|
||||
expect(calls).toEqual ['child']
|
||||
|
||||
calls = []
|
||||
disposable2.dispose()
|
||||
grandchild.dispatchEvent(new CustomEvent('command', bubbles: true))
|
||||
expect(calls).toEqual []
|
||||
|
||||
it "allows multiple commands to be registered under one selector when called with an object", ->
|
||||
calls = []
|
||||
|
||||
disposable = registry.add '.child',
|
||||
'command-1': -> calls.push('command-1')
|
||||
'command-2': -> calls.push('command-2')
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command-1', bubbles: true))
|
||||
grandchild.dispatchEvent(new CustomEvent('command-2', bubbles: true))
|
||||
|
||||
expect(calls).toEqual ['command-1', 'command-2']
|
||||
|
||||
calls = []
|
||||
disposable.dispose()
|
||||
grandchild.dispatchEvent(new CustomEvent('command-1', bubbles: true))
|
||||
grandchild.dispatchEvent(new CustomEvent('command-2', bubbles: true))
|
||||
expect(calls).toEqual []
|
||||
|
||||
it "invokes callbacks registered with ::onWillDispatch and ::onDidDispatch", ->
|
||||
sequence = []
|
||||
|
||||
registry.onDidDispatch (event) ->
|
||||
sequence.push ['onDidDispatch', event]
|
||||
|
||||
registry.add '.grandchild', 'command', (event) ->
|
||||
sequence.push ['listener', event]
|
||||
|
||||
registry.onWillDispatch (event) ->
|
||||
sequence.push ['onWillDispatch', event]
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command', bubbles: true))
|
||||
|
||||
expect(sequence[0][0]).toBe 'onWillDispatch'
|
||||
expect(sequence[1][0]).toBe 'listener'
|
||||
expect(sequence[2][0]).toBe 'onDidDispatch'
|
||||
|
||||
expect(sequence[0][1] is sequence[1][1] is sequence[2][1]).toBe true
|
||||
expect(sequence[0][1].constructor).toBe CustomEvent
|
||||
expect(sequence[0][1].target).toBe grandchild
|
||||
|
||||
describe "::add(selector, commandName, callback)", ->
|
||||
it "throws an error when called with an invalid selector", ->
|
||||
badSelector = '<>'
|
||||
addError = null
|
||||
try
|
||||
registry.add badSelector, 'foo:bar', ->
|
||||
catch error
|
||||
addError = error
|
||||
expect(addError.message).toContain(badSelector)
|
||||
|
||||
it "throws an error when called with a non-function callback and selector target", ->
|
||||
badCallback = null
|
||||
addError = null
|
||||
|
||||
try
|
||||
registry.add '.selector', 'foo:bar', badCallback
|
||||
catch error
|
||||
addError = error
|
||||
expect(addError.message).toContain("Can't register a command with non-function callback.")
|
||||
|
||||
it "throws an error when called with an non-function callback and object target", ->
|
||||
badCallback = null
|
||||
addError = null
|
||||
|
||||
try
|
||||
registry.add document.body, 'foo:bar', badCallback
|
||||
catch error
|
||||
addError = error
|
||||
expect(addError.message).toContain("Can't register a command with non-function callback.")
|
||||
|
||||
describe "::findCommands({target})", ->
|
||||
it "returns commands that can be invoked on the target or its ancestors", ->
|
||||
registry.add '.parent', 'namespace:command-1', ->
|
||||
registry.add '.child', 'namespace:command-2', ->
|
||||
registry.add '.grandchild', 'namespace:command-3', ->
|
||||
registry.add '.grandchild.no-match', 'namespace:command-4', ->
|
||||
|
||||
registry.add grandchild, 'namespace:inline-command-1', ->
|
||||
registry.add child, 'namespace:inline-command-2', ->
|
||||
|
||||
commands = registry.findCommands(target: grandchild)
|
||||
nonJqueryCommands = _.reject commands, (cmd) -> cmd.jQuery
|
||||
expect(nonJqueryCommands).toEqual [
|
||||
{name: 'namespace:inline-command-1', displayName: 'Namespace: Inline Command 1'}
|
||||
{name: 'namespace:command-3', displayName: 'Namespace: Command 3'}
|
||||
{name: 'namespace:inline-command-2', displayName: 'Namespace: Inline Command 2'}
|
||||
{name: 'namespace:command-2', displayName: 'Namespace: Command 2'}
|
||||
{name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
|
||||
]
|
||||
|
||||
describe "::dispatch(target, commandName)", ->
|
||||
it "simulates invocation of the given command ", ->
|
||||
called = false
|
||||
registry.add '.grandchild', 'command', (event) ->
|
||||
expect(this).toBe grandchild
|
||||
expect(event.type).toBe 'command'
|
||||
expect(event.eventPhase).toBe Event.BUBBLING_PHASE
|
||||
expect(event.target).toBe grandchild
|
||||
expect(event.currentTarget).toBe grandchild
|
||||
called = true
|
||||
|
||||
registry.dispatch(grandchild, 'command')
|
||||
expect(called).toBe true
|
||||
|
||||
it "returns a boolean indicating whether any listeners matched the command", ->
|
||||
registry.add '.grandchild', 'command', ->
|
||||
|
||||
expect(registry.dispatch(grandchild, 'command')).toBe true
|
||||
expect(registry.dispatch(grandchild, 'bogus')).toBe false
|
||||
expect(registry.dispatch(parent, 'command')).toBe false
|
||||
|
||||
describe "::getSnapshot and ::restoreSnapshot", ->
|
||||
it "removes all command handlers except for those in the snapshot", ->
|
||||
registry.add '.parent', 'namespace:command-1', ->
|
||||
registry.add '.child', 'namespace:command-2', ->
|
||||
snapshot = registry.getSnapshot()
|
||||
registry.add '.grandchild', 'namespace:command-3', ->
|
||||
|
||||
expect(registry.findCommands(target: grandchild)[0..2]).toEqual [
|
||||
{name: 'namespace:command-3', displayName: 'Namespace: Command 3'}
|
||||
{name: 'namespace:command-2', displayName: 'Namespace: Command 2'}
|
||||
{name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
|
||||
]
|
||||
|
||||
registry.restoreSnapshot(snapshot)
|
||||
|
||||
expect(registry.findCommands(target: grandchild)[0..1]).toEqual [
|
||||
{name: 'namespace:command-2', displayName: 'Namespace: Command 2'}
|
||||
{name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
|
||||
]
|
||||
|
||||
registry.add '.grandchild', 'namespace:command-3', ->
|
||||
registry.restoreSnapshot(snapshot)
|
||||
|
||||
expect(registry.findCommands(target: grandchild)[0..1]).toEqual [
|
||||
{name: 'namespace:command-2', displayName: 'Namespace: Command 2'}
|
||||
{name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
|
||||
]
|
||||
|
||||
describe "::attach(rootNode)", ->
|
||||
it "adds event listeners for any previously-added commands", ->
|
||||
registry2 = new CommandRegistry
|
||||
|
||||
commandSpy = jasmine.createSpy('command-callback')
|
||||
registry2.add '.grandchild', 'command-1', commandSpy
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command-1', bubbles: true))
|
||||
expect(commandSpy).not.toHaveBeenCalled()
|
||||
|
||||
registry2.attach(parent)
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command-1', bubbles: true))
|
||||
expect(commandSpy).toHaveBeenCalled()
|
||||
345
spec/command-registry-spec.js
Normal file
345
spec/command-registry-spec.js
Normal file
@@ -0,0 +1,345 @@
|
||||
const CommandRegistry = require('../src/command-registry');
|
||||
const _ = require('underscore-plus');
|
||||
|
||||
describe("CommandRegistry", () => {
|
||||
let registry, parent, child, grandchild;
|
||||
|
||||
beforeEach(() => {
|
||||
parent = document.createElement("div");
|
||||
child = document.createElement("div");
|
||||
grandchild = document.createElement("div");
|
||||
parent.classList.add('parent');
|
||||
child.classList.add('child');
|
||||
grandchild.classList.add('grandchild');
|
||||
child.appendChild(grandchild);
|
||||
parent.appendChild(child);
|
||||
document.querySelector('#jasmine-content').appendChild(parent);
|
||||
|
||||
registry = new CommandRegistry;
|
||||
registry.attach(parent);
|
||||
});
|
||||
|
||||
afterEach(() => registry.destroy());
|
||||
|
||||
describe("when a command event is dispatched on an element", () => {
|
||||
it("invokes callbacks with selectors matching the target", () => {
|
||||
let called = false;
|
||||
registry.add('.grandchild', 'command', function (event) {
|
||||
expect(this).toBe(grandchild);
|
||||
expect(event.type).toBe('command');
|
||||
expect(event.eventPhase).toBe(Event.BUBBLING_PHASE);
|
||||
expect(event.target).toBe(grandchild);
|
||||
expect(event.currentTarget).toBe(grandchild);
|
||||
called = true;
|
||||
});
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
it("invokes callbacks with selectors matching ancestors of the target", () => {
|
||||
const calls = [];
|
||||
|
||||
registry.add('.child', 'command', function (event) {
|
||||
expect(this).toBe(child);
|
||||
expect(event.target).toBe(grandchild);
|
||||
expect(event.currentTarget).toBe(child);
|
||||
calls.push('child');
|
||||
});
|
||||
|
||||
registry.add('.parent', 'command', function (event) {
|
||||
expect(this).toBe(parent);
|
||||
expect(event.target).toBe(grandchild);
|
||||
expect(event.currentTarget).toBe(parent);
|
||||
calls.push('parent');
|
||||
});
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
|
||||
expect(calls).toEqual(['child', 'parent']);
|
||||
});
|
||||
|
||||
it("invokes inline listeners prior to listeners applied via selectors", () => {
|
||||
const calls = [];
|
||||
registry.add('.grandchild', 'command', () => calls.push('grandchild'));
|
||||
registry.add(child, 'command', () => calls.push('child-inline'));
|
||||
registry.add('.child', 'command', () => calls.push('child'));
|
||||
registry.add('.parent', 'command', () => calls.push('parent'));
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
|
||||
expect(calls).toEqual(['grandchild', 'child-inline', 'child', 'parent']);
|
||||
});
|
||||
|
||||
it("orders multiple matching listeners for an element by selector specificity", () => {
|
||||
child.classList.add('foo', 'bar');
|
||||
const calls = [];
|
||||
|
||||
registry.add('.foo.bar', 'command', () => calls.push('.foo.bar'));
|
||||
registry.add('.foo', 'command', () => calls.push('.foo'));
|
||||
registry.add('.bar', 'command', () => calls.push('.bar')); // specificity ties favor commands added later, like CSS
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
|
||||
expect(calls).toEqual(['.foo.bar', '.bar', '.foo']);
|
||||
});
|
||||
|
||||
it("orders inline listeners by reverse registration order", () => {
|
||||
const calls = [];
|
||||
registry.add(child, 'command', () => calls.push('child1'));
|
||||
registry.add(child, 'command', () => calls.push('child2'));
|
||||
child.dispatchEvent(new CustomEvent('command', {bubbles: true}));
|
||||
expect(calls).toEqual(['child2', 'child1']);
|
||||
});
|
||||
|
||||
it("stops bubbling through ancestors when .stopPropagation() is called on the event", () => {
|
||||
const calls = [];
|
||||
|
||||
registry.add('.parent', 'command', () => calls.push('parent'));
|
||||
registry.add('.child', 'command', () => calls.push('child-2'));
|
||||
registry.add('.child', 'command', (event) => {
|
||||
calls.push('child-1');
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
const dispatchedEvent = new CustomEvent('command', {bubbles: true});
|
||||
spyOn(dispatchedEvent, 'stopPropagation');
|
||||
grandchild.dispatchEvent(dispatchedEvent);
|
||||
expect(calls).toEqual(['child-1', 'child-2']);
|
||||
expect(dispatchedEvent.stopPropagation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops invoking callbacks when .stopImmediatePropagation() is called on the event", () => {
|
||||
const calls = [];
|
||||
|
||||
registry.add('.parent', 'command', () => calls.push('parent'));
|
||||
registry.add('.child', 'command', () => calls.push('child-2'));
|
||||
registry.add('.child', 'command', (event) => {
|
||||
calls.push('child-1');
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
|
||||
const dispatchedEvent = new CustomEvent('command', {bubbles: true});
|
||||
spyOn(dispatchedEvent, 'stopImmediatePropagation');
|
||||
grandchild.dispatchEvent(dispatchedEvent);
|
||||
expect(calls).toEqual(['child-1']);
|
||||
expect(dispatchedEvent.stopImmediatePropagation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards .preventDefault() calls from the synthetic event to the original", () => {
|
||||
registry.add('.child', 'command', event => event.preventDefault());
|
||||
|
||||
const dispatchedEvent = new CustomEvent('command', {bubbles: true});
|
||||
spyOn(dispatchedEvent, 'preventDefault');
|
||||
grandchild.dispatchEvent(dispatchedEvent);
|
||||
expect(dispatchedEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards .abortKeyBinding() calls from the synthetic event to the original", () => {
|
||||
registry.add('.child', 'command', event => event.abortKeyBinding());
|
||||
|
||||
const dispatchedEvent = new CustomEvent('command', {bubbles: true});
|
||||
dispatchedEvent.abortKeyBinding = jasmine.createSpy('abortKeyBinding');
|
||||
grandchild.dispatchEvent(dispatchedEvent);
|
||||
expect(dispatchedEvent.abortKeyBinding).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies non-standard properties from the original event to the synthetic event", () => {
|
||||
let syntheticEvent = null;
|
||||
registry.add('.child', 'command', event => syntheticEvent = event);
|
||||
|
||||
const dispatchedEvent = new CustomEvent('command', {bubbles: true});
|
||||
dispatchedEvent.nonStandardProperty = 'testing';
|
||||
grandchild.dispatchEvent(dispatchedEvent);
|
||||
expect(syntheticEvent.nonStandardProperty).toBe('testing');
|
||||
});
|
||||
|
||||
it("allows listeners to be removed via a disposable returned by ::add", () => {
|
||||
let calls = [];
|
||||
|
||||
const disposable1 = registry.add('.parent', 'command', () => calls.push('parent'));
|
||||
const disposable2 = registry.add('.child', 'command', () => calls.push('child'));
|
||||
|
||||
disposable1.dispose();
|
||||
grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
|
||||
expect(calls).toEqual(['child']);
|
||||
|
||||
calls = [];
|
||||
disposable2.dispose();
|
||||
grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows multiple commands to be registered under one selector when called with an object", () => {
|
||||
let calls = [];
|
||||
|
||||
const disposable = registry.add('.child', {
|
||||
'command-1'() {
|
||||
calls.push('command-1');
|
||||
},
|
||||
'command-2'() {
|
||||
calls.push('command-2');
|
||||
}
|
||||
});
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true}));
|
||||
grandchild.dispatchEvent(new CustomEvent('command-2', {bubbles: true}));
|
||||
|
||||
expect(calls).toEqual(['command-1', 'command-2']);
|
||||
|
||||
calls = [];
|
||||
disposable.dispose();
|
||||
grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true}));
|
||||
grandchild.dispatchEvent(new CustomEvent('command-2', {bubbles: true}));
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("invokes callbacks registered with ::onWillDispatch and ::onDidDispatch", () => {
|
||||
const sequence = [];
|
||||
|
||||
registry.onDidDispatch(event => sequence.push(['onDidDispatch', event]));
|
||||
|
||||
registry.add('.grandchild', 'command', event => sequence.push(['listener', event]));
|
||||
|
||||
registry.onWillDispatch(event => sequence.push(['onWillDispatch', event]));
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command', {bubbles: true}));
|
||||
|
||||
expect(sequence[0][0]).toBe('onWillDispatch');
|
||||
expect(sequence[1][0]).toBe('listener');
|
||||
expect(sequence[2][0]).toBe('onDidDispatch');
|
||||
|
||||
expect(sequence[0][1] === sequence[1][1] && sequence[1][1] === sequence[2][1]).toBe(true);
|
||||
expect(sequence[0][1].constructor).toBe(CustomEvent);
|
||||
expect(sequence[0][1].target).toBe(grandchild);
|
||||
});
|
||||
});
|
||||
|
||||
describe("::add(selector, commandName, callback)", () => {
|
||||
it("throws an error when called with an invalid selector", () => {
|
||||
const badSelector = '<>';
|
||||
let addError = null;
|
||||
try {
|
||||
registry.add(badSelector, 'foo:bar', () => {});
|
||||
} catch (error) {
|
||||
addError = error;
|
||||
}
|
||||
expect(addError.message).toContain(badSelector);
|
||||
});
|
||||
|
||||
it("throws an error when called with a non-function callback and selector target", () => {
|
||||
const badCallback = null;
|
||||
let addError = null;
|
||||
|
||||
try {
|
||||
registry.add('.selector', 'foo:bar', badCallback);
|
||||
} catch (error) {
|
||||
addError = error;
|
||||
}
|
||||
expect(addError.message).toContain("Can't register a command with non-function callback.");
|
||||
});
|
||||
|
||||
it("throws an error when called with an non-function callback and object target", () => {
|
||||
const badCallback = null;
|
||||
let addError = null;
|
||||
|
||||
try {
|
||||
registry.add(document.body, 'foo:bar', badCallback);
|
||||
} catch (error) {
|
||||
addError = error;
|
||||
}
|
||||
expect(addError.message).toContain("Can't register a command with non-function callback.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("::findCommands({target})", () =>
|
||||
it("returns commands that can be invoked on the target or its ancestors", () => {
|
||||
registry.add('.parent', 'namespace:command-1', () => {});
|
||||
registry.add('.child', 'namespace:command-2', () => {});
|
||||
registry.add('.grandchild', 'namespace:command-3', () => {});
|
||||
registry.add('.grandchild.no-match', 'namespace:command-4', () => {});
|
||||
|
||||
registry.add(grandchild, 'namespace:inline-command-1', () => {});
|
||||
registry.add(child, 'namespace:inline-command-2', () => {});
|
||||
|
||||
const commands = registry.findCommands({target: grandchild});
|
||||
const nonJqueryCommands = _.reject(commands, cmd => cmd.jQuery);
|
||||
expect(nonJqueryCommands).toEqual([
|
||||
{name: 'namespace:inline-command-1', displayName: 'Namespace: Inline Command 1'},
|
||||
{name: 'namespace:command-3', displayName: 'Namespace: Command 3'},
|
||||
{name: 'namespace:inline-command-2', displayName: 'Namespace: Inline Command 2'},
|
||||
{name: 'namespace:command-2', displayName: 'Namespace: Command 2'},
|
||||
{name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
describe("::dispatch(target, commandName)", () => {
|
||||
it("simulates invocation of the given command ", () => {
|
||||
let called = false;
|
||||
registry.add('.grandchild', 'command', function (event) {
|
||||
expect(this).toBe(grandchild);
|
||||
expect(event.type).toBe('command');
|
||||
expect(event.eventPhase).toBe(Event.BUBBLING_PHASE);
|
||||
expect(event.target).toBe(grandchild);
|
||||
expect(event.currentTarget).toBe(grandchild);
|
||||
called = true;
|
||||
});
|
||||
|
||||
registry.dispatch(grandchild, 'command');
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a boolean indicating whether any listeners matched the command", () => {
|
||||
registry.add('.grandchild', 'command', () => {});
|
||||
|
||||
expect(registry.dispatch(grandchild, 'command')).toBe(true);
|
||||
expect(registry.dispatch(grandchild, 'bogus')).toBe(false);
|
||||
expect(registry.dispatch(parent, 'command')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("::getSnapshot and ::restoreSnapshot", () =>
|
||||
it("removes all command handlers except for those in the snapshot", () => {
|
||||
registry.add('.parent', 'namespace:command-1', () => {});
|
||||
registry.add('.child', 'namespace:command-2', () => {});
|
||||
const snapshot = registry.getSnapshot();
|
||||
registry.add('.grandchild', 'namespace:command-3', () => {});
|
||||
|
||||
expect(registry.findCommands({target: grandchild}).slice(0, 3)).toEqual([
|
||||
{name: 'namespace:command-3', displayName: 'Namespace: Command 3'},
|
||||
{name: 'namespace:command-2', displayName: 'Namespace: Command 2'},
|
||||
{name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
|
||||
]);
|
||||
|
||||
registry.restoreSnapshot(snapshot);
|
||||
|
||||
expect(registry.findCommands({target: grandchild}).slice(0, 2)).toEqual([
|
||||
{name: 'namespace:command-2', displayName: 'Namespace: Command 2'},
|
||||
{name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
|
||||
]);
|
||||
|
||||
registry.add('.grandchild', 'namespace:command-3', () => {});
|
||||
registry.restoreSnapshot(snapshot);
|
||||
|
||||
expect(registry.findCommands({target: grandchild}).slice(0, 2)).toEqual([
|
||||
{name: 'namespace:command-2', displayName: 'Namespace: Command 2'},
|
||||
{name: 'namespace:command-1', displayName: 'Namespace: Command 1'}
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
describe("::attach(rootNode)", () =>
|
||||
it("adds event listeners for any previously-added commands", () => {
|
||||
const registry2 = new CommandRegistry;
|
||||
|
||||
const commandSpy = jasmine.createSpy('command-callback');
|
||||
registry2.add('.grandchild', 'command-1', commandSpy);
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true}));
|
||||
expect(commandSpy).not.toHaveBeenCalled();
|
||||
|
||||
registry2.attach(parent);
|
||||
|
||||
grandchild.dispatchEvent(new CustomEvent('command-1', {bubbles: true}));
|
||||
expect(commandSpy).toHaveBeenCalled();
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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
406
src/command-registry.js
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user