From b96e560c8dc10c18f4010f04f37df91c89a56870 Mon Sep 17 00:00:00 2001 From: Marc Laval Date: Thu, 2 Apr 2015 15:56:58 +0200 Subject: [PATCH] feat(events): add support for global events Fixes #1098 Closes #1255 --- .../src/core/annotations/annotations.js | 12 +- .../src/core/compiler/proto_view_factory.js | 8 +- modules/angular2/src/core/compiler/view.js | 18 +-- modules/angular2/src/dom/browser_adapter.dart | 15 +++ modules/angular2/src/dom/browser_adapter.es6 | 15 +++ modules/angular2/src/dom/dom_adapter.js | 6 + modules/angular2/src/dom/html_adapter.dart | 3 + modules/angular2/src/dom/parse5_adapter.cjs | 3 + .../angular2/src/mock/vm_turn_zone_mock.js | 2 +- modules/angular2/src/render/api.js | 14 ++- .../render/dom/compiler/directive_parser.js | 12 +- .../src/render/dom/events/event_manager.js | 47 +++++--- modules/angular2/src/render/dom/util.js | 2 + .../src/render/dom/view/element_binder.js | 21 +++- .../src/render/dom/view/proto_view_builder.js | 103 +++++++++++------- modules/angular2/src/render/dom/view/view.js | 34 +++++- .../src/render/dom/view/view_factory.js | 10 +- .../core/compiler/element_injector_spec.js | 7 +- .../test/core/compiler/integration_spec.js | 99 ++++++++++++++++- .../dom/compiler/directive_parser_spec.js | 27 ++++- .../compiler/property_binding_parser_spec.js | 20 +++- .../render/dom/events/event_manager_spec.js | 26 ++++- .../test/render/dom/integration_testbed.js | 1 + ...mulated_scoped_shadow_dom_strategy_spec.js | 2 +- ...lated_unscoped_shadow_dom_strategy_spec.js | 2 +- .../native_shadow_dom_strategy_spec.js | 2 +- .../test/render/dom/view/view_spec.js | 6 +- 27 files changed, 414 insertions(+), 103 deletions(-) diff --git a/modules/angular2/src/core/annotations/annotations.js b/modules/angular2/src/core/annotations/annotations.js index 106895f16c..50ca758372 100644 --- a/modules/angular2/src/core/annotations/annotations.js +++ b/modules/angular2/src/core/annotations/annotations.js @@ -367,6 +367,8 @@ export class Directive extends Injectable { * - `event1`: the DOM event that the directive listens to. * - `statement`: the statement to execute when the event occurs. * + * To listen to global events, a target must be added to the event name. + * The target can be `window`, `document` or `body`. * * When writing a directive event binding, you can also refer to the following local variables: * - `$event`: Current event object which triggered the event. @@ -380,6 +382,7 @@ export class Directive extends Injectable { * @Directive({ * hostListeners: { * 'event1': 'onMethod1(arguments)', + * 'target:event2': 'onMethod2(arguments)', * ... * } * } @@ -387,19 +390,22 @@ export class Directive extends Injectable { * * ## Basic Event Binding: * - * Suppose you want to write a directive that triggers on `change` hostListeners in the DOM. You would define the event - * binding as follows: + * Suppose you want to write a directive that triggers on `change` events in the DOM and on `resize` events in window. + * You would define the event binding as follows: * * ``` * @Decorator({ * selector: 'input', * hostListeners: { - * 'change': 'onChange($event)' + * 'change': 'onChange($event)', + * 'window:resize': 'onResize($event)' * } * }) * class InputDecorator { * onChange(event:Event) { * } + * onResize(event:Event) { + * } * } * ``` * diff --git a/modules/angular2/src/core/compiler/proto_view_factory.js b/modules/angular2/src/core/compiler/proto_view_factory.js index 4cfdde684a..a0141e2fb6 100644 --- a/modules/angular2/src/core/compiler/proto_view_factory.js +++ b/modules/angular2/src/core/compiler/proto_view_factory.js @@ -118,9 +118,7 @@ export class ProtoViewFactory { protoView.bindElementProperty(astWithSource.ast, propertyName); }); // events - MapWrapper.forEach(renderElementBinder.eventBindings, (astWithSource, eventName) => { - protoView.bindEvent(eventName, astWithSource.ast, -1); - }); + protoView.bindEvent(renderElementBinder.eventBindings, -1); // variables // The view's locals needs to have a full set of variable names at construction time // in order to prevent new variables from being set later in the lifecycle. Since we don't want @@ -143,9 +141,7 @@ export class ProtoViewFactory { protoView.bindDirectiveProperty(i, astWithSource.ast, propertyName, setter); }); // directive events - MapWrapper.forEach(renderDirectiveMetadata.eventBindings, (astWithSource, eventName) => { - protoView.bindEvent(eventName, astWithSource.ast, i); - }); + protoView.bindEvent(renderDirectiveMetadata.eventBindings, i); } } diff --git a/modules/angular2/src/core/compiler/view.js b/modules/angular2/src/core/compiler/view.js index 12aa74d522..c07b3224f1 100644 --- a/modules/angular2/src/core/compiler/view.js +++ b/modules/angular2/src/core/compiler/view.js @@ -272,7 +272,7 @@ export class AppView { var elBinder = this.proto.elementBinders[elementIndex]; if (isBlank(elBinder.hostListeners)) return; var eventMap = elBinder.hostListeners[eventName]; - if (isBlank(eventName)) return; + if (isBlank(eventMap)) return; MapWrapper.forEach(eventMap, (expr, directiveIndex) => { var context; if (directiveIndex === -1) { @@ -407,19 +407,23 @@ export class AppProtoView { * @param {int} directiveIndex The directive index in the binder or -1 when the event is not bound * to a directive */ - bindEvent(eventName:string, expression:AST, directiveIndex: int = -1) { + bindEvent(eventBindings: List, directiveIndex: int = -1) { var elBinder = this.elementBinders[this.elementBinders.length - 1]; var events = elBinder.hostListeners; if (isBlank(events)) { events = StringMapWrapper.create(); elBinder.hostListeners = events; } - var event = StringMapWrapper.get(events, eventName); - if (isBlank(event)) { - event = MapWrapper.create(); - StringMapWrapper.set(events, eventName, event); + for (var i = 0; i < eventBindings.length; i++) { + var eventBinding = eventBindings[i]; + var eventName = eventBinding.fullName; + var event = StringMapWrapper.get(events, eventName); + if (isBlank(event)) { + event = MapWrapper.create(); + StringMapWrapper.set(events, eventName, event); + } + MapWrapper.set(event, directiveIndex, eventBinding.source); } - MapWrapper.set(event, directiveIndex, expression); } /** diff --git a/modules/angular2/src/dom/browser_adapter.dart b/modules/angular2/src/dom/browser_adapter.dart index 08c824c29b..3ac8d50d79 100644 --- a/modules/angular2/src/dom/browser_adapter.dart +++ b/modules/angular2/src/dom/browser_adapter.dart @@ -119,6 +119,12 @@ class BrowserDomAdapter extends GenericBrowserDomAdapter { // addEventListener misses zones so we use element.on. element.on[event].listen(callback); } + Function onAndCancel(EventTarget element, String event, callback(arg)) { + // due to https://code.google.com/p/dart/issues/detail?id=17406 + // addEventListener misses zones so we use element.on. + var subscription = element.on[event].listen(callback); + return subscription.cancel; + } void dispatchEvent(EventTarget el, Event evt) { el.dispatchEvent(evt); } @@ -288,4 +294,13 @@ class BrowserDomAdapter extends GenericBrowserDomAdapter { int keyCode = event.keyCode; return _keyCodeToKeyMap.containsKey(keyCode) ? _keyCodeToKeyMap[keyCode] : 'Unidentified'; } + getGlobalEventTarget(String target) { + if (target == "window") { + return window; + } else if (target == "document") { + return document; + } else if (target == "body") { + return document.body; + } + } } diff --git a/modules/angular2/src/dom/browser_adapter.es6 b/modules/angular2/src/dom/browser_adapter.es6 index 4a40bd4499..ddabf8a038 100644 --- a/modules/angular2/src/dom/browser_adapter.es6 +++ b/modules/angular2/src/dom/browser_adapter.es6 @@ -73,6 +73,12 @@ export class BrowserDomAdapter extends GenericBrowserDomAdapter { on(el, evt, listener) { el.addEventListener(evt, listener, false); } + onAndCancel(el, evt, listener): Function { + el.addEventListener(evt, listener, false); + //Needed to follow Dart's subscription semantic, until fix of + //https://code.google.com/p/dart/issues/detail?id=17406 + return () => {el.removeEventListener(evt, listener, false);}; + } dispatchEvent(el, evt) { el.dispatchEvent(evt); } @@ -353,4 +359,13 @@ export class BrowserDomAdapter extends GenericBrowserDomAdapter { } return key; } + getGlobalEventTarget(target:string) { + if (target == "window") { + return window; + } else if (target == "document") { + return document; + } else if (target == "body") { + return document.body; + } + } } diff --git a/modules/angular2/src/dom/dom_adapter.js b/modules/angular2/src/dom/dom_adapter.js index 95302d532b..8465d15e88 100644 --- a/modules/angular2/src/dom/dom_adapter.js +++ b/modules/angular2/src/dom/dom_adapter.js @@ -39,6 +39,9 @@ export class DomAdapter { on(el, evt, listener) { throw _abstract(); } + onAndCancel(el, evt, listener): Function { + throw _abstract(); + } dispatchEvent(el, evt) { throw _abstract(); } @@ -273,4 +276,7 @@ export class DomAdapter { supportsNativeShadowDOM(): boolean { throw _abstract(); } + getGlobalEventTarget(target:string) { + throw _abstract(); + } } diff --git a/modules/angular2/src/dom/html_adapter.dart b/modules/angular2/src/dom/html_adapter.dart index fe513d40a4..2576f58913 100644 --- a/modules/angular2/src/dom/html_adapter.dart +++ b/modules/angular2/src/dom/html_adapter.dart @@ -29,6 +29,9 @@ class Html5LibDomAdapter implements DomAdapter { on(el, evt, listener) { throw 'not implemented'; } + Function onAndCancel(el, evt, listener) { + throw 'not implemented'; + } dispatchEvent(el, evt) { throw 'not implemented'; } diff --git a/modules/angular2/src/dom/parse5_adapter.cjs b/modules/angular2/src/dom/parse5_adapter.cjs index 83a8e78b57..e16136e34d 100644 --- a/modules/angular2/src/dom/parse5_adapter.cjs +++ b/modules/angular2/src/dom/parse5_adapter.cjs @@ -86,6 +86,9 @@ export class Parse5DomAdapter extends DomAdapter { on(el, evt, listener) { //Do nothing, in order to not break forms integration tests } + onAndCancel(el, evt, listener): Function { + //Do nothing, in order to not break forms integration tests + } dispatchEvent(el, evt) { throw _notImplemented('dispatchEvent'); } diff --git a/modules/angular2/src/mock/vm_turn_zone_mock.js b/modules/angular2/src/mock/vm_turn_zone_mock.js index 0dcd479caa..1aa08f64c2 100644 --- a/modules/angular2/src/mock/vm_turn_zone_mock.js +++ b/modules/angular2/src/mock/vm_turn_zone_mock.js @@ -10,6 +10,6 @@ export class MockVmTurnZone extends VmTurnZone { } runOutsideAngular(fn) { - fn(); + return fn(); } } diff --git a/modules/angular2/src/render/api.js b/modules/angular2/src/render/api.js index d17f8f75c1..1ddc5ce7ff 100644 --- a/modules/angular2/src/render/api.js +++ b/modules/angular2/src/render/api.js @@ -15,6 +15,16 @@ import {ASTWithSource} from 'angular2/change_detection'; * - render compiler is not on the critical path as * its output will be stored in precompiled templates. */ +export class EventBinding { + fullName: string; // name/target:name, e.g "click", "window:resize" + source: ASTWithSource; + + constructor(fullName :string, source: ASTWithSource) { + this.fullName = fullName; + this.source = source; + } +} + export class ElementBinder { index:number; parentIndex:number; @@ -26,7 +36,7 @@ export class ElementBinder { // Note: this contains a preprocessed AST // that replaced the values that should be extracted from the element // with a local name - eventBindings: Map; + eventBindings: List; textBindings: List; readAttributes: Map; @@ -57,7 +67,7 @@ export class DirectiveBinder { // Note: this contains a preprocessed AST // that replaced the values that should be extracted from the element // with a local name - eventBindings: Map; + eventBindings: List; constructor({ directiveIndex, propertyBindings, eventBindings }) { diff --git a/modules/angular2/src/render/dom/compiler/directive_parser.js b/modules/angular2/src/render/dom/compiler/directive_parser.js index 87ddc9ff51..d4d28157eb 100644 --- a/modules/angular2/src/render/dom/compiler/directive_parser.js +++ b/modules/angular2/src/render/dom/compiler/directive_parser.js @@ -1,4 +1,4 @@ -import {isPresent, isBlank, BaseException, assertionsEnabled, RegExpWrapper} from 'angular2/src/facade/lang'; +import {isPresent, isBlank, BaseException, assertionsEnabled, RegExpWrapper, StringWrapper} from 'angular2/src/facade/lang'; import {List, MapWrapper, ListWrapper} from 'angular2/src/facade/collection'; import {DOM} from 'angular2/src/dom/dom_adapter'; import {Parser} from 'angular2/change_detection'; @@ -10,7 +10,7 @@ import {CompileElement} from './compile_element'; import {CompileControl} from './compile_control'; import {DirectiveMetadata} from '../../api'; -import {dashCaseToCamelCase, camelCaseToDashCase} from '../util'; +import {dashCaseToCamelCase, camelCaseToDashCase, EVENT_TARGET_SEPARATOR} from '../util'; /** * Parses the directives on a single element. Assumes ViewSplitter has already created @@ -132,7 +132,13 @@ export class DirectiveParser extends CompileStep { _bindDirectiveEvent(eventName, action, compileElement, directiveBinder) { var ast = this._parser.parseAction(action, compileElement.elementDescription); - directiveBinder.bindEvent(eventName, ast); + if (StringWrapper.contains(eventName, EVENT_TARGET_SEPARATOR)) { + var parts = eventName.split(EVENT_TARGET_SEPARATOR); + directiveBinder.bindEvent(parts[1], ast, parts[0]); + } else { + directiveBinder.bindEvent(eventName, ast); + } + } _splitBindConfig(bindConfig:string) { diff --git a/modules/angular2/src/render/dom/events/event_manager.js b/modules/angular2/src/render/dom/events/event_manager.js index 78d5854c7b..408086d98f 100644 --- a/modules/angular2/src/render/dom/events/event_manager.js +++ b/modules/angular2/src/render/dom/events/event_manager.js @@ -18,13 +18,15 @@ export class EventManager { } addEventListener(element, eventName: string, handler: Function) { - var shouldSupportBubble = eventName[0] == BUBBLE_SYMBOL; - if (shouldSupportBubble) { - eventName = StringWrapper.substring(eventName, 1); - } + var withoutBubbleSymbol = this._removeBubbleSymbol(eventName); + var plugin = this._findPluginFor(withoutBubbleSymbol); + plugin.addEventListener(element, withoutBubbleSymbol, handler, withoutBubbleSymbol != eventName); + } - var plugin = this._findPluginFor(eventName); - plugin.addEventListener(element, eventName, handler, shouldSupportBubble); + addGlobalEventListener(target: string, eventName: string, handler: Function): Function { + var withoutBubbleSymbol = this._removeBubbleSymbol(eventName); + var plugin = this._findPluginFor(withoutBubbleSymbol); + return plugin.addGlobalEventListener(target, withoutBubbleSymbol, handler, withoutBubbleSymbol != eventName); } getZone(): VmTurnZone { @@ -41,6 +43,10 @@ export class EventManager { } throw new BaseException(`No event manager plugin found for event ${eventName}`); } + + _removeBubbleSymbol(eventName: string): string { + return eventName[0] == BUBBLE_SYMBOL ? StringWrapper.substring(eventName, 1) : eventName; + } } export class EventManagerPlugin { @@ -54,8 +60,11 @@ export class EventManagerPlugin { return false; } - addEventListener(element, eventName: string, handler: Function, - shouldSupportBubble: boolean) { + addEventListener(element, eventName: string, handler: Function, shouldSupportBubble: boolean) { + throw "not implemented"; + } + + addGlobalEventListener(element, eventName: string, handler: Function, shouldSupportBubble: boolean): Function { throw "not implemented"; } } @@ -69,17 +78,27 @@ export class DomEventsPlugin extends EventManagerPlugin { return true; } - addEventListener(element, eventName: string, handler: Function, - shouldSupportBubble: boolean) { - var outsideHandler = shouldSupportBubble ? - DomEventsPlugin.bubbleCallback(element, handler, this.manager._zone) : - DomEventsPlugin.sameElementCallback(element, handler, this.manager._zone); - + addEventListener(element, eventName: string, handler: Function, shouldSupportBubble: boolean) { + var outsideHandler = this._getOutsideHandler(shouldSupportBubble, element, handler, this.manager._zone); this.manager._zone.runOutsideAngular(() => { DOM.on(element, eventName, outsideHandler); }); } + addGlobalEventListener(target:string, eventName: string, handler: Function, shouldSupportBubble: boolean): Function { + var element = DOM.getGlobalEventTarget(target); + var outsideHandler = this._getOutsideHandler(shouldSupportBubble, element, handler, this.manager._zone); + return this.manager._zone.runOutsideAngular(() => { + return DOM.onAndCancel(element, eventName, outsideHandler); + }); + } + + _getOutsideHandler(shouldSupportBubble: boolean, element, handler: Function, zone: VmTurnZone) { + return shouldSupportBubble ? + DomEventsPlugin.bubbleCallback(element, handler, zone) : + DomEventsPlugin.sameElementCallback(element, handler, zone); + } + static sameElementCallback(element, handler, zone) { return (event) => { if (event.target === element) { diff --git a/modules/angular2/src/render/dom/util.js b/modules/angular2/src/render/dom/util.js index 843752eb18..0ca98b1327 100644 --- a/modules/angular2/src/render/dom/util.js +++ b/modules/angular2/src/render/dom/util.js @@ -3,6 +3,8 @@ import {StringWrapper, RegExpWrapper, isPresent} from 'angular2/src/facade/lang' export const NG_BINDING_CLASS_SELECTOR = '.ng-binding'; export const NG_BINDING_CLASS = 'ng-binding'; +export const EVENT_TARGET_SEPARATOR = ':'; + var CAMEL_CASE_REGEXP = RegExpWrapper.create('([A-Z])'); var DASH_CASE_REGEXP = RegExpWrapper.create('-([a-z])'); diff --git a/modules/angular2/src/render/dom/view/element_binder.js b/modules/angular2/src/render/dom/view/element_binder.js index d1c31e146b..9c978f8f15 100644 --- a/modules/angular2/src/render/dom/view/element_binder.js +++ b/modules/angular2/src/render/dom/view/element_binder.js @@ -8,7 +8,8 @@ export class ElementBinder { textNodeIndices: List; nestedProtoView: protoViewModule.RenderProtoView; eventLocals: AST; - eventNames: List; + localEvents: List; + globalEvents: List; componentId: string; parentIndex:number; distanceToParent:number; @@ -20,7 +21,8 @@ export class ElementBinder { nestedProtoView, componentId, eventLocals, - eventNames, + localEvents, + globalEvents, parentIndex, distanceToParent, propertySetters @@ -30,9 +32,22 @@ export class ElementBinder { this.nestedProtoView = nestedProtoView; this.componentId = componentId; this.eventLocals = eventLocals; - this.eventNames = eventNames; + this.localEvents = localEvents; + this.globalEvents = globalEvents; this.parentIndex = parentIndex; this.distanceToParent = distanceToParent; this.propertySetters = propertySetters; } } + +export class Event { + name: string; + target: string; + fullName: string; + + constructor(name: string, target: string, fullName: string) { + this.name = name; + this.target = target; + this.fullName = fullName; + } +} diff --git a/modules/angular2/src/render/dom/view/proto_view_builder.js b/modules/angular2/src/render/dom/view/proto_view_builder.js index 3c45b8e191..28a194097e 100644 --- a/modules/angular2/src/render/dom/view/proto_view_builder.js +++ b/modules/angular2/src/render/dom/view/proto_view_builder.js @@ -1,5 +1,5 @@ import {isPresent, isBlank, BaseException} from 'angular2/src/facade/lang'; -import {ListWrapper, MapWrapper, Set, SetWrapper} from 'angular2/src/facade/collection'; +import {ListWrapper, MapWrapper, Set, SetWrapper, List} from 'angular2/src/facade/collection'; import {DOM} from 'angular2/src/dom/dom_adapter'; import { @@ -8,13 +8,13 @@ import { import {SetterFn} from 'angular2/src/reflection/types'; import {RenderProtoView} from './proto_view'; -import {ElementBinder} from './element_binder'; +import {ElementBinder, Event} from './element_binder'; import {setterFactory} from './property_setter_factory'; import * as api from '../../api'; import * as directDomRenderer from '../direct_dom_renderer'; -import {NG_BINDING_CLASS} from '../util'; +import {NG_BINDING_CLASS, EVENT_TARGET_SEPARATOR} from '../util'; export class ProtoViewBuilder { rootElement; @@ -56,12 +56,12 @@ export class ProtoViewBuilder { var apiElementBinders = []; ListWrapper.forEach(this.elements, (ebb) => { var propertySetters = MapWrapper.create(); - var eventLocalsAstSplitter = new EventLocalsAstSplitter(); var apiDirectiveBinders = ListWrapper.map(ebb.directives, (db) => { + ebb.eventBuilder.merge(db.eventBuilder); return new api.DirectiveBinder({ directiveIndex: db.directiveIndex, propertyBindings: db.propertyBindings, - eventBindings: eventLocalsAstSplitter.splitEventAstIntoLocals(db.eventBindings) + eventBindings: db.eventBindings }); }); MapWrapper.forEach(ebb.propertySetters, (setter, propertyName) => { @@ -75,7 +75,7 @@ export class ProtoViewBuilder { directives: apiDirectiveBinders, nestedProtoView: nestedProtoView, propertyBindings: ebb.propertyBindings, variableBindings: ebb.variableBindings, - eventBindings: eventLocalsAstSplitter.splitEventAstIntoLocals(ebb.eventBindings), + eventBindings: ebb.eventBindings, textBindings: ebb.textBindings, readAttributes: ebb.readAttributes })); @@ -86,8 +86,9 @@ export class ProtoViewBuilder { distanceToParent: ebb.distanceToParent, nestedProtoView: isPresent(nestedProtoView) ? nestedProtoView.render.delegate : null, componentId: ebb.componentId, - eventLocals: eventLocalsAstSplitter.buildEventLocals(), - eventNames: eventLocalsAstSplitter.buildEventNames(), + eventLocals: new LiteralArray(ebb.eventBuilder.buildEventLocals()), + localEvents: ebb.eventBuilder.buildLocalEvents(), + globalEvents: ebb.eventBuilder.buildGlobalEvents(), propertySetters: propertySetters })); }); @@ -112,7 +113,8 @@ export class ElementBinderBuilder { nestedProtoView:ProtoViewBuilder; propertyBindings: Map; variableBindings: Map; - eventBindings: Map; + eventBindings: List; + eventBuilder: EventBuilder; textBindingIndices: List; textBindings: List; contentTagSelector:string; @@ -129,7 +131,8 @@ export class ElementBinderBuilder { this.nestedProtoView = null; this.propertyBindings = MapWrapper.create(); this.variableBindings = MapWrapper.create(); - this.eventBindings = MapWrapper.create(); + this.eventBindings = ListWrapper.create(); + this.eventBuilder = new EventBuilder(); this.textBindings = []; this.textBindingIndices = []; this.contentTagSelector = null; @@ -191,8 +194,8 @@ export class ElementBinderBuilder { } } - bindEvent(name, expression) { - MapWrapper.set(this.eventBindings, name, expression); + bindEvent(name, expression, target = null) { + ListWrapper.push(this.eventBindings, this.eventBuilder.add(name, expression, target)); } bindText(index, expression) { @@ -212,49 +215,53 @@ export class ElementBinderBuilder { export class DirectiveBuilder { directiveIndex:number; propertyBindings: Map; - eventBindings: Map; + eventBindings: List; + eventBuilder: EventBuilder; constructor(directiveIndex) { this.directiveIndex = directiveIndex; this.propertyBindings = MapWrapper.create(); - this.eventBindings = MapWrapper.create(); + this.eventBindings = ListWrapper.create(); + this.eventBuilder = new EventBuilder(); } bindProperty(name, expression) { MapWrapper.set(this.propertyBindings, name, expression); } - bindEvent(name, expression) { - MapWrapper.set(this.eventBindings, name, expression); + bindEvent(name, expression, target = null) { + ListWrapper.push(this.eventBindings, this.eventBuilder.add(name, expression, target)); } } -export class EventLocalsAstSplitter extends AstTransformer { - locals:List; - eventNames:List; - _implicitReceiver:AST; +export class EventBuilder extends AstTransformer { + locals: List; + localEvents: List; + globalEvents: List; + _implicitReceiver: AST; constructor() { super(); this.locals = []; - this.eventNames = []; + this.localEvents = []; + this.globalEvents = []; this._implicitReceiver = new ImplicitReceiver(); } - splitEventAstIntoLocals(eventBindings:Map):Map { - if (isPresent(eventBindings)) { - var result = MapWrapper.create(); - MapWrapper.forEach(eventBindings, (astWithSource, eventName) => { - // TODO(tbosch): reenable this when we are parsing element properties - // out of action expressions - // var adjustedAst = astWithSource.ast.visit(this); - var adjustedAst = astWithSource.ast; - MapWrapper.set(result, eventName, new ASTWithSource(adjustedAst, astWithSource.source, '')); - ListWrapper.push(this.eventNames, eventName); - }); - return result; + add(name: string, source: ASTWithSource, target: string): api.EventBinding { + // TODO(tbosch): reenable this when we are parsing element properties + // out of action expressions + // var adjustedAst = astWithSource.ast.visit(this); + var adjustedAst = source.ast; + var fullName = isPresent(target) ? target + EVENT_TARGET_SEPARATOR + name : name; + var result = new api.EventBinding(fullName, new ASTWithSource(adjustedAst, source.source, '')); + var event = new Event(name, target, fullName); + if (isBlank(target)) { + ListWrapper.push(this.localEvents, event); + } else { + ListWrapper.push(this.globalEvents, event); } - return null; + return result; } visitAccessMember(ast:AccessMember) { @@ -277,10 +284,32 @@ export class EventLocalsAstSplitter extends AstTransformer { } buildEventLocals() { - return new LiteralArray(this.locals); + return this.locals; } - buildEventNames() { - return this.eventNames; + buildLocalEvents() { + return this.localEvents; + } + + buildGlobalEvents() { + return this.globalEvents; + } + + merge(eventBuilder: EventBuilder) { + this._merge(this.localEvents, eventBuilder.localEvents); + this._merge(this.globalEvents, eventBuilder.globalEvents); + ListWrapper.concat(this.locals, eventBuilder.locals); + } + + _merge(host: List, tobeAdded: List) { + var names = ListWrapper.create(); + for (var i = 0; i < host.length; i++) { + ListWrapper.push(names, host[i].fullName); + } + for (var j = 0; j < tobeAdded.length; j++) { + if (!ListWrapper.contains(names, tobeAdded[j].fullName)) { + ListWrapper.push(host, tobeAdded[j]); + } + } } } diff --git a/modules/angular2/src/render/dom/view/view.js b/modules/angular2/src/render/dom/view/view.js index d45c075bcb..a366e2d58d 100644 --- a/modules/angular2/src/render/dom/view/view.js +++ b/modules/angular2/src/render/dom/view/view.js @@ -6,6 +6,7 @@ import {ViewContainer} from './view_container'; import {RenderProtoView} from './proto_view'; import {LightDom} from '../shadow_dom/light_dom'; import {Content} from '../shadow_dom/content_tag'; +import {EventManager} from 'angular2/src/render/dom/events/event_manager'; import {ShadowDomStrategy} from '../shadow_dom/shadow_dom_strategy'; @@ -29,12 +30,14 @@ export class RenderView { contentTags: List; lightDoms: List; proto: RenderProtoView; + eventManager: EventManager; _hydrated: boolean; _eventDispatcher: any/*EventDispatcher*/; + _eventHandlerRemovers: List; constructor( proto:RenderProtoView, rootNodes:List, - boundTextNodes: List, boundElements:List, viewContainers:List, contentTags:List) { + boundTextNodes: List, boundElements:List, viewContainers:List, contentTags:List, eventManager: EventManager) { this.proto = proto; this.rootNodes = rootNodes; this.boundTextNodes = boundTextNodes; @@ -42,9 +45,11 @@ export class RenderView { this.viewContainers = viewContainers; this.contentTags = contentTags; this.lightDoms = ListWrapper.createFixedSize(boundElements.length); + this.eventManager = eventManager; ListWrapper.fill(this.lightDoms, null); this.componentChildViews = ListWrapper.createFixedSize(boundElements.length); this._hydrated = false; + this._eventHandlerRemovers = null; } hydrated() { @@ -130,6 +135,26 @@ export class RenderView { lightDom.redistribute(); } } + + //add global events + this._eventHandlerRemovers = ListWrapper.create(); + var binders = this.proto.elementBinders; + for (var binderIdx = 0; binderIdx < binders.length; binderIdx++) { + var binder = binders[binderIdx]; + if (isPresent(binder.globalEvents)) { + for (var i = 0; i < binder.globalEvents.length; i++) { + var globalEvent = binder.globalEvents[i]; + var remover = this._createGlobalEventListener(binderIdx, globalEvent.name, globalEvent.target, globalEvent.fullName); + ListWrapper.push(this._eventHandlerRemovers, remover); + } + } + } + } + + _createGlobalEventListener(elementIndex, eventName, eventTarget, fullName): Function { + return this.eventManager.addGlobalEventListener(eventTarget, eventName, (event) => { + this.dispatchEvent(elementIndex, fullName, event); + }); } dehydrate() { @@ -156,6 +181,13 @@ export class RenderView { } } } + + //remove global events + for (var i = 0; i < this._eventHandlerRemovers.length; i++) { + this._eventHandlerRemovers[i](); + } + + this._eventHandlerRemovers = null; this._eventDispatcher = null; this._hydrated = false; } diff --git a/modules/angular2/src/render/dom/view/view_factory.js b/modules/angular2/src/render/dom/view/view_factory.js index 26e077cc01..e50129b1f6 100644 --- a/modules/angular2/src/render/dom/view/view_factory.js +++ b/modules/angular2/src/render/dom/view/view_factory.js @@ -125,7 +125,7 @@ export class ViewFactory { var view = new viewModule.RenderView( protoView, viewRootNodes, - boundTextNodes, boundElements, viewContainers, contentTags + boundTextNodes, boundElements, viewContainers, contentTags, this._eventManager ); for (var binderIdx = 0; binderIdx < binders.length; binderIdx++) { @@ -139,10 +139,10 @@ export class ViewFactory { } // events - if (isPresent(binder.eventLocals)) { - ListWrapper.forEach(binder.eventNames, (eventName) => { - this._createEventListener(view, element, binderIdx, eventName, binder.eventLocals); - }); + if (isPresent(binder.eventLocals) && isPresent(binder.localEvents)) { + for (var i = 0; i < binder.localEvents.length; i++) { + this._createEventListener(view, element, binderIdx, binder.localEvents[i].name, binder.eventLocals); + } } } diff --git a/modules/angular2/test/core/compiler/element_injector_spec.js b/modules/angular2/test/core/compiler/element_injector_spec.js index d9c0017ce5..968db80bb3 100644 --- a/modules/angular2/test/core/compiler/element_injector_spec.js +++ b/modules/angular2/test/core/compiler/element_injector_spec.js @@ -11,8 +11,7 @@ import {ViewContainer} from 'angular2/src/core/compiler/view_container'; import {NgElement} from 'angular2/src/core/compiler/ng_element'; import {Directive} from 'angular2/src/core/annotations/annotations'; import {BindingPropagationConfig, Parser, Lexer} from 'angular2/change_detection'; - -import {ViewRef, Renderer} from 'angular2/src/render/api'; +import {ViewRef, Renderer, EventBinding} from 'angular2/src/render/api'; import {QueryList} from 'angular2/src/core/compiler/query_list'; class DummyDirective extends Directive { @@ -701,7 +700,9 @@ export function main() { StringMapWrapper.set(handlers, eventName, eventHandler); var pv = new AppProtoView(null, null, null); pv.bindElement(null, 0, null, null, null); - pv.bindEvent(eventName, new Parser(new Lexer()).parseAction('handler()', '')); + var eventBindings = ListWrapper.create(); + ListWrapper.push(eventBindings, new EventBinding(eventName, new Parser(new Lexer()).parseAction('handler()', ''))); + pv.bindEvent(eventBindings); var view = new AppView(pv, MapWrapper.create()); view.context = new ContextWithHandler(eventHandler); diff --git a/modules/angular2/test/core/compiler/integration_spec.js b/modules/angular2/test/core/compiler/integration_spec.js index 3631373bed..e0d57324cf 100644 --- a/modules/angular2/test/core/compiler/integration_spec.js +++ b/modules/angular2/test/core/compiler/integration_spec.js @@ -17,7 +17,7 @@ import { import {TestBed} from 'angular2/src/test_lib/test_bed'; import {DOM} from 'angular2/src/dom/dom_adapter'; -import {Type, isPresent, BaseException, assertionsEnabled, isJsObject} from 'angular2/src/facade/lang'; +import {Type, isPresent, BaseException, assertionsEnabled, isJsObject, global} from 'angular2/src/facade/lang'; import {PromiseWrapper} from 'angular2/src/facade/async'; import {Injector, bind} from 'angular2/di'; @@ -541,6 +541,66 @@ export function main() { async.done(); }); })); + + it('should support render global events', inject([TestBed, AsyncTestCompleter], (tb, async) => { + tb.overrideView(MyComp, new View({ + template: '
', + directives: [DecoratorListeningDomEvent] + })); + + tb.createView(MyComp, {context: ctx}).then((view) => { + var injector = view.rawView.elementInjectors[0]; + + var listener = injector.get(DecoratorListeningDomEvent); + dispatchEvent(DOM.getGlobalEventTarget("window"), 'domEvent'); + expect(listener.eventType).toEqual('window_domEvent'); + + listener = injector.get(DecoratorListeningDomEvent); + dispatchEvent(DOM.getGlobalEventTarget("document"), 'domEvent'); + expect(listener.eventType).toEqual('document_domEvent'); + + view.rawView.dehydrate(); + listener = injector.get(DecoratorListeningDomEvent); + dispatchEvent(DOM.getGlobalEventTarget("body"), 'domEvent'); + expect(listener.eventType).toEqual(''); + + async.done(); + }); + })); + + it('should support render global events from multiple directives', inject([TestBed, AsyncTestCompleter], (tb, async) => { + tb.overrideView(MyComp, new View({ + template: '
', + directives: [If, DecoratorListeningDomEvent, DecoratorListeningDomEventOther] + })); + + tb.createView(MyComp, {context: ctx}).then((view) => { + globalCounter = 0; + ctx.ctxBoolProp = true; + view.detectChanges(); + + var subview = view.rawView.viewContainers[0].get(0); + var injector = subview.elementInjectors[0]; + var listener = injector.get(DecoratorListeningDomEvent); + var listenerother = injector.get(DecoratorListeningDomEventOther); + dispatchEvent(DOM.getGlobalEventTarget("window"), 'domEvent'); + expect(listener.eventType).toEqual('window_domEvent'); + expect(listenerother.eventType).toEqual('other_domEvent'); + expect(globalCounter).toEqual(1); + + ctx.ctxBoolProp = false; + view.detectChanges(); + dispatchEvent(DOM.getGlobalEventTarget("window"), 'domEvent'); + expect(globalCounter).toEqual(1); + + ctx.ctxBoolProp = true; + view.detectChanges(); + dispatchEvent(DOM.getGlobalEventTarget("window"), 'domEvent'); + expect(globalCounter).toEqual(2); + + async.done(); + }); + })); } describe("dynamic components", () => { @@ -894,18 +954,49 @@ class DecoratorListeningEvent { @Decorator({ selector: '[listener]', - hostListeners: {'domEvent': 'onEvent($event.type)'} + hostListeners: { + 'domEvent': 'onEvent($event.type)', + 'window:domEvent': 'onWindowEvent($event.type)', + 'document:domEvent': 'onDocumentEvent($event.type)', + 'body:domEvent': 'onBodyEvent($event.type)' + } }) class DecoratorListeningDomEvent { eventType: string; - constructor() { this.eventType = ''; } - onEvent(eventType: string) { this.eventType = eventType; } + onWindowEvent(eventType: string) { + this.eventType = "window_" + eventType; + } + onDocumentEvent(eventType: string) { + this.eventType = "document_" + eventType; + } + onBodyEvent(eventType: string) { + this.eventType = "body_" + eventType; + } +} + +var globalCounter = 0; +@Decorator({ + selector: '[listenerother]', + hostListeners: { + 'window:domEvent': 'onEvent($event.type)' + } +}) +class DecoratorListeningDomEventOther { + eventType: string; + counter: int; + constructor() { + this.eventType = ''; + } + onEvent(eventType: string) { + globalCounter++; + this.eventType = "other_" + eventType; + } } @Component({ diff --git a/modules/angular2/test/render/dom/compiler/directive_parser_spec.js b/modules/angular2/test/render/dom/compiler/directive_parser_spec.js index 38883f97ba..69f472d3ca 100644 --- a/modules/angular2/test/render/dom/compiler/directive_parser_spec.js +++ b/modules/angular2/test/render/dom/compiler/directive_parser_spec.js @@ -23,7 +23,8 @@ export function main() { someDecorator, someDecoratorIgnoringChildren, someDecoratorWithProps, - someDecoratorWithEvents + someDecoratorWithEvents, + someDecoratorWithGlobalEvents ]; parser = new Parser(new Lexer()); }); @@ -130,8 +131,21 @@ export function main() { el('
') ); var directiveBinding = results[0].directives[0]; - expect(MapWrapper.get(directiveBinding.eventBindings, 'click').source) - .toEqual('doIt()'); + expect(directiveBinding.eventBindings.length).toEqual(1); + var eventBinding = directiveBinding.eventBindings[0]; + expect(eventBinding.fullName).toEqual('click'); + expect(eventBinding.source.source).toEqual('doIt()'); + }); + + it('should bind directive global events', () => { + var results = process( + el('
') + ); + var directiveBinding = results[0].directives[0]; + expect(directiveBinding.eventBindings.length).toEqual(1); + var eventBinding = directiveBinding.eventBindings[0]; + expect(eventBinding.fullName).toEqual('window:resize'); + expect(eventBinding.source.source).toEqual('doItGlobal()'); }); describe('viewport directives', () => { @@ -246,3 +260,10 @@ var someDecoratorWithEvents = new DirectiveMetadata({ 'click': 'doIt()' }) }); + +var someDecoratorWithGlobalEvents = new DirectiveMetadata({ + selector: '[some-decor-globalevents]', + hostListeners: MapWrapper.createFromStringMap({ + 'window:resize': 'doItGlobal()' + }) +}); diff --git a/modules/angular2/test/render/dom/compiler/property_binding_parser_spec.js b/modules/angular2/test/render/dom/compiler/property_binding_parser_spec.js index a880493f93..c2d5fbf144 100644 --- a/modules/angular2/test/render/dom/compiler/property_binding_parser_spec.js +++ b/modules/angular2/test/render/dom/compiler/property_binding_parser_spec.js @@ -96,10 +96,14 @@ export function main() { it('should detect () syntax', () => { var results = process(el('
')); - expect(MapWrapper.get(results[0].eventBindings, 'click').source).toEqual('b()'); + var eventBinding = results[0].eventBindings[0]; + expect(eventBinding.source.source).toEqual('b()'); + expect(eventBinding.fullName).toEqual('click'); // "(click[])" is not an expected syntax and is only used to validate the regexp results = process(el('
')); - expect(MapWrapper.get(results[0].eventBindings, 'click[]').source).toEqual('b()'); + eventBinding = results[0].eventBindings[0]; + expect(eventBinding.source.source).toEqual('b()'); + expect(eventBinding.fullName).toEqual('click[]'); }); it('should detect () syntax only if an attribute name starts and ends with ()', () => { @@ -109,17 +113,23 @@ export function main() { it('should parse event handlers using () syntax as actions', () => { var results = process(el('
')); - expect(MapWrapper.get(results[0].eventBindings, 'click').source).toEqual('foo=bar'); + var eventBinding = results[0].eventBindings[0]; + expect(eventBinding.source.source).toEqual('foo=bar'); + expect(eventBinding.fullName).toEqual('click'); }); it('should detect on- syntax', () => { var results = process(el('
')); - expect(MapWrapper.get(results[0].eventBindings, 'click').source).toEqual('b()'); + var eventBinding = results[0].eventBindings[0]; + expect(eventBinding.source.source).toEqual('b()'); + expect(eventBinding.fullName).toEqual('click'); }); it('should parse event handlers using on- syntax as actions', () => { var results = process(el('
')); - expect(MapWrapper.get(results[0].eventBindings, 'click').source).toEqual('foo=bar'); + var eventBinding = results[0].eventBindings[0]; + expect(eventBinding.source.source).toEqual('foo=bar'); + expect(eventBinding.fullName).toEqual('click'); }); it('should store bound properties as temporal attributes', () => { diff --git a/modules/angular2/test/render/dom/events/event_manager_spec.js b/modules/angular2/test/render/dom/events/event_manager_spec.js index 81d0c89967..b57fb6caa1 100644 --- a/modules/angular2/test/render/dom/events/event_manager_spec.js +++ b/modules/angular2/test/render/dom/events/event_manager_spec.js @@ -83,6 +83,28 @@ export function main() { expect(receivedEvent).toBe(dispatchedEvent); }); + + it('should add and remove global event listeners with correct bubbling', () => { + var element = el('
'); + DOM.appendChild(document.body, element); + var dispatchedEvent = DOM.createMouseEvent('click'); + var receivedEvent = null; + var handler = (e) => { receivedEvent = e; }; + var manager = new EventManager([domEventPlugin], new FakeVmTurnZone()); + + var remover = manager.addGlobalEventListener("document", '^click', handler); + DOM.dispatchEvent(element, dispatchedEvent); + expect(receivedEvent).toBe(dispatchedEvent); + + receivedEvent = null; + remover(); + DOM.dispatchEvent(element, dispatchedEvent); + expect(receivedEvent).toBe(null); + + remover = manager.addGlobalEventListener("document", 'click', handler); + DOM.dispatchEvent(element, dispatchedEvent); + expect(receivedEvent).toBe(null); + }); }); } @@ -104,6 +126,8 @@ class FakeEventManagerPlugin extends EventManagerPlugin { addEventListener(element, eventName: string, handler: Function, shouldSupportBubble: boolean) { MapWrapper.set(shouldSupportBubble ? this._bubbleEventHandlers : this._nonBubbleEventHandlers, eventName, handler); + return () => {MapWrapper.delete(shouldSupportBubble ? this._bubbleEventHandlers : this._nonBubbleEventHandlers, + eventName)}; } } @@ -117,6 +141,6 @@ class FakeVmTurnZone extends VmTurnZone { } runOutsideAngular(fn) { - fn(); + return fn(); } } diff --git a/modules/angular2/test/render/dom/integration_testbed.js b/modules/angular2/test/render/dom/integration_testbed.js index 117f966c97..5e4850d193 100644 --- a/modules/angular2/test/render/dom/integration_testbed.js +++ b/modules/angular2/test/render/dom/integration_testbed.js @@ -173,6 +173,7 @@ export class FakeEventManagerPlugin extends EventManagerPlugin { addEventListener(element, eventName: string, handler: Function, shouldSupportBubble: boolean) { MapWrapper.set(this._eventHandlers, eventName, handler); + return () => {MapWrapper.delete(this._eventHandlers, eventName);} } } diff --git a/modules/angular2/test/render/dom/shadow_dom/emulated_scoped_shadow_dom_strategy_spec.js b/modules/angular2/test/render/dom/shadow_dom/emulated_scoped_shadow_dom_strategy_spec.js index 2d7d2c4e7f..9b1324b898 100644 --- a/modules/angular2/test/render/dom/shadow_dom/emulated_scoped_shadow_dom_strategy_spec.js +++ b/modules/angular2/test/render/dom/shadow_dom/emulated_scoped_shadow_dom_strategy_spec.js @@ -47,7 +47,7 @@ export function main() { it('should attach the view nodes as child of the host element', () => { var host = el('
original content
'); var nodes = el('
view
'); - var view = new RenderView(null, [nodes], [], [], [], []); + var view = new RenderView(null, [nodes], [], [], [], [], null); strategy.attachTemplate(host, view); var firstChild = DOM.firstChild(host); diff --git a/modules/angular2/test/render/dom/shadow_dom/emulated_unscoped_shadow_dom_strategy_spec.js b/modules/angular2/test/render/dom/shadow_dom/emulated_unscoped_shadow_dom_strategy_spec.js index e36e3cf65e..5eff1cc29f 100644 --- a/modules/angular2/test/render/dom/shadow_dom/emulated_unscoped_shadow_dom_strategy_spec.js +++ b/modules/angular2/test/render/dom/shadow_dom/emulated_unscoped_shadow_dom_strategy_spec.js @@ -42,7 +42,7 @@ export function main() { it('should attach the view nodes as child of the host element', () => { var host = el('
original content
'); var nodes = el('
view
'); - var view = new RenderView(null, [nodes], [], [], [], []); + var view = new RenderView(null, [nodes], [], [], [], [], null); strategy.attachTemplate(host, view); var firstChild = DOM.firstChild(host); diff --git a/modules/angular2/test/render/dom/shadow_dom/native_shadow_dom_strategy_spec.js b/modules/angular2/test/render/dom/shadow_dom/native_shadow_dom_strategy_spec.js index 21f3f28020..ab13611b75 100644 --- a/modules/angular2/test/render/dom/shadow_dom/native_shadow_dom_strategy_spec.js +++ b/modules/angular2/test/render/dom/shadow_dom/native_shadow_dom_strategy_spec.js @@ -35,7 +35,7 @@ export function main() { it('should attach the view nodes to the shadow root', () => { var host = el('
original content
'); var nodes = el('
view
'); - var view = new RenderView(null, [nodes], [], [], [], []); + var view = new RenderView(null, [nodes], [], [], [], [], null); strategy.attachTemplate(host, view); var shadowRoot = DOM.getShadowRoot(host); diff --git a/modules/angular2/test/render/dom/view/view_spec.js b/modules/angular2/test/render/dom/view/view_spec.js index 7368f66f49..e7e2399cc7 100644 --- a/modules/angular2/test/render/dom/view/view_spec.js +++ b/modules/angular2/test/render/dom/view/view_spec.js @@ -2,6 +2,7 @@ import {describe, ddescribe, it, iit, xit, xdescribe, expect, beforeEach, el} fr import {ListWrapper} from 'angular2/src/facade/collection'; +import {RenderProtoView} from 'angular2/src/render/dom/view/proto_view'; import {RenderView} from 'angular2/src/render/dom/view/view'; import {ShadowDomStrategy} from 'angular2/src/render/dom/shadow_dom/shadow_dom_strategy'; import {LightDom} from 'angular2/src/render/dom/shadow_dom/light_dom'; @@ -9,14 +10,15 @@ import {LightDom} from 'angular2/src/render/dom/shadow_dom/light_dom'; export function main() { function createView() { - var proto = null; + var proto = new RenderProtoView({element: el('
'), isRootView: false, elementBinders: []}); var rootNodes = [el('
')]; var boundTextNodes = []; var boundElements = [el('
')]; var viewContainers = []; var contentTags = []; + var eventManager = null; return new RenderView(proto, rootNodes, - boundTextNodes, boundElements, viewContainers, contentTags); + boundTextNodes, boundElements, viewContainers, contentTags, eventManager); } function createShadowDomStrategy(log) {