feat(platform-browser): allow lazy-loading HammerJS (#23906)
PR Close #23906
This commit is contained in:
parent
5cf82f8f3f
commit
313bdce590
|
@ -6,7 +6,7 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Inject, Injectable, InjectionToken, ɵConsole as Console} from '@angular/core';
|
import {Inject, Injectable, InjectionToken, Optional, ɵConsole as Console} from '@angular/core';
|
||||||
|
|
||||||
import {DOCUMENT} from '../dom_tokens';
|
import {DOCUMENT} from '../dom_tokens';
|
||||||
|
|
||||||
|
@ -58,6 +58,13 @@ const EVENT_NAMES = {
|
||||||
*/
|
*/
|
||||||
export const HAMMER_GESTURE_CONFIG = new InjectionToken<HammerGestureConfig>('HammerGestureConfig');
|
export const HAMMER_GESTURE_CONFIG = new InjectionToken<HammerGestureConfig>('HammerGestureConfig');
|
||||||
|
|
||||||
|
|
||||||
|
/** Function that loads HammerJS, returning a promise that is resolved once HammerJs is loaded. */
|
||||||
|
export type HammerLoader = (() => Promise<void>) | null;
|
||||||
|
|
||||||
|
/** Injection token used to provide a {@link HammerLoader} to Angular. */
|
||||||
|
export const HAMMER_LOADER = new InjectionToken<HammerLoader>('HammerLoader');
|
||||||
|
|
||||||
export interface HammerInstance {
|
export interface HammerInstance {
|
||||||
on(eventName: string, callback?: Function): void;
|
on(eventName: string, callback?: Function): void;
|
||||||
off(eventName: string, callback?: Function): void;
|
off(eventName: string, callback?: Function): void;
|
||||||
|
@ -99,8 +106,8 @@ export class HammerGestureConfig {
|
||||||
export class HammerGesturesPlugin extends EventManagerPlugin {
|
export class HammerGesturesPlugin extends EventManagerPlugin {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DOCUMENT) doc: any,
|
@Inject(DOCUMENT) doc: any,
|
||||||
@Inject(HAMMER_GESTURE_CONFIG) private _config: HammerGestureConfig,
|
@Inject(HAMMER_GESTURE_CONFIG) private _config: HammerGestureConfig, private console: Console,
|
||||||
private console: Console) {
|
@Optional() @Inject(HAMMER_LOADER) private loader?: HammerLoader) {
|
||||||
super(doc);
|
super(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,8 +116,10 @@ export class HammerGesturesPlugin extends EventManagerPlugin {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(window as any).Hammer) {
|
if (!(window as any).Hammer && !this.loader) {
|
||||||
this.console.warn(`Hammer.js is not loaded, can not bind '${eventName}' event.`);
|
this.console.warn(
|
||||||
|
`The "${eventName}" event cannot be bound because Hammer.JS is not ` +
|
||||||
|
`loaded and no custom loader has been specified.`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -121,6 +130,44 @@ export class HammerGesturesPlugin extends EventManagerPlugin {
|
||||||
const zone = this.manager.getZone();
|
const zone = this.manager.getZone();
|
||||||
eventName = eventName.toLowerCase();
|
eventName = eventName.toLowerCase();
|
||||||
|
|
||||||
|
// If Hammer is not present but a loader is specified, we defer adding the event listener
|
||||||
|
// until Hammer is loaded.
|
||||||
|
if (!(window as any).Hammer && this.loader) {
|
||||||
|
// This `addEventListener` method returns a function to remove the added listener.
|
||||||
|
// Until Hammer is loaded, the returned function needs to *cancel* the registration rather
|
||||||
|
// than remove anything.
|
||||||
|
let cancelRegistration = false;
|
||||||
|
let deregister: Function = () => { cancelRegistration = true; };
|
||||||
|
|
||||||
|
this.loader()
|
||||||
|
.then(() => {
|
||||||
|
// If Hammer isn't actually loaded when the custom loader resolves, give up.
|
||||||
|
if (!(window as any).Hammer) {
|
||||||
|
this.console.warn(
|
||||||
|
`The custom HAMMER_LOADER completed, but Hammer.JS is not present.`);
|
||||||
|
deregister = () => {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelRegistration) {
|
||||||
|
// Now that Hammer is loaded and the listener is being loaded for real,
|
||||||
|
// the deregistration function changes from canceling registration to removal.
|
||||||
|
deregister = this.addEventListener(element, eventName, handler);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.console.warn(
|
||||||
|
`The "${eventName}" event cannot be bound because the custom ` +
|
||||||
|
`Hammer.JS loader failed.`);
|
||||||
|
deregister = () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return a function that *executes* `deregister` (and not `deregister` itself) so that we
|
||||||
|
// can change the behavior of `deregister` once the listener is added. Using a closure in
|
||||||
|
// this way allows us to avoid any additional data structures to track listener removal.
|
||||||
|
return () => { deregister(); };
|
||||||
|
}
|
||||||
|
|
||||||
return zone.runOutsideAngular(() => {
|
return zone.runOutsideAngular(() => {
|
||||||
// Creating the manager bind events, must be done outside of angular
|
// Creating the manager bind events, must be done outside of angular
|
||||||
const mc = this._config.buildHammer(element);
|
const mc = this._config.buildHammer(element);
|
||||||
|
|
|
@ -5,32 +5,150 @@
|
||||||
* Use of this source code is governed by an MIT-style license that can be
|
* Use of this source code is governed by an MIT-style license that can be
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
import {describe, expect, it} from '@angular/core/testing/src/testing_internal';
|
import {NgZone} from '@angular/core';
|
||||||
import {HammerGestureConfig, HammerGesturesPlugin} from '@angular/platform-browser/src/dom/events/hammer_gestures';
|
import {fakeAsync, inject, tick} from '@angular/core/testing';
|
||||||
|
import {afterEach, beforeEach, describe, expect, it,} from '@angular/core/testing/src/testing_internal';
|
||||||
|
import {EventManager} from '@angular/platform-browser';
|
||||||
|
import {HammerGestureConfig, HammerGesturesPlugin,} from '@angular/platform-browser/src/dom/events/hammer_gestures';
|
||||||
|
|
||||||
{
|
{
|
||||||
describe('HammerGesturesPlugin', () => {
|
describe('HammerGesturesPlugin', () => {
|
||||||
let plugin: HammerGesturesPlugin;
|
let plugin: HammerGesturesPlugin;
|
||||||
let mockConsole: any;
|
let fakeConsole: any;
|
||||||
if (isNode) return;
|
if (isNode) return;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => { fakeConsole = {warn: jasmine.createSpy('console.warn')}; });
|
||||||
mockConsole = {warn: () => {}};
|
|
||||||
plugin = new HammerGesturesPlugin(document, new HammerGestureConfig(), mockConsole);
|
describe('with no custom loader', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
plugin = new HammerGesturesPlugin(document, new HammerGestureConfig(), fakeConsole);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should implement addGlobalEventListener', () => {
|
||||||
|
spyOn(plugin, 'addEventListener').and.callFake(() => {});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
plugin.addGlobalEventListener('document', 'swipe', () => {});
|
||||||
|
}).not.toThrowError();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn user and do nothing when Hammer.js not loaded', () => {
|
||||||
|
expect(plugin.supports('swipe')).toBe(false);
|
||||||
|
expect(fakeConsole.warn)
|
||||||
|
.toHaveBeenCalledWith(
|
||||||
|
`The "swipe" event cannot be bound because Hammer.JS is not ` +
|
||||||
|
`loaded and no custom loader has been specified.`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should implement addGlobalEventListener', () => {
|
describe('with a custom loader', () => {
|
||||||
spyOn(plugin, 'addEventListener').and.callFake(() => {});
|
// Use a fake custom loader for tests, with helper functions to resolve or reject.
|
||||||
|
let loader: () => Promise<void>;
|
||||||
|
let resolveLoader: () => void;
|
||||||
|
let failLoader: () => void;
|
||||||
|
|
||||||
expect(() => plugin.addGlobalEventListener('document', 'swipe', () => {})).not.toThrowError();
|
// Arbitrary element and listener for testing.
|
||||||
});
|
let someElement: HTMLDivElement;
|
||||||
|
let someListener: () => void;
|
||||||
|
|
||||||
it('should warn user and do nothing when Hammer.js not loaeded', () => {
|
// Keep track of whatever value is in `window.Hammer` before the test so it can be
|
||||||
spyOn(mockConsole, 'warn');
|
// restored afterwards so that this test doesn't care whether Hammer is actually loaded.
|
||||||
|
let originalHammerGlobal: any;
|
||||||
|
|
||||||
expect(plugin.supports('swipe')).toBe(false);
|
// Fake Hammer instance ("mc") used to test the underlying event registration.
|
||||||
expect(mockConsole.warn)
|
let fakeHammerInstance: {on: () => void, off: () => void};
|
||||||
.toHaveBeenCalledWith(`Hammer.js is not loaded, can not bind 'swipe' event.`);
|
|
||||||
|
// Inject the NgZone so that we can make it available to the plugin through a fake
|
||||||
|
// EventManager.
|
||||||
|
let ngZone: NgZone;
|
||||||
|
beforeEach(inject([NgZone], (z: NgZone) => { ngZone = z; }));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalHammerGlobal = (window as any).Hammer;
|
||||||
|
(window as any).Hammer = undefined;
|
||||||
|
|
||||||
|
fakeHammerInstance = {
|
||||||
|
on: jasmine.createSpy('mc.on'),
|
||||||
|
off: jasmine.createSpy('mc.off'),
|
||||||
|
};
|
||||||
|
|
||||||
|
loader = () => new Promise((resolve, reject) => {
|
||||||
|
resolveLoader = resolve;
|
||||||
|
failLoader = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make the hammer config return a fake hammer instance
|
||||||
|
const hammerConfig = new HammerGestureConfig();
|
||||||
|
spyOn(hammerConfig, 'buildHammer').and.returnValue(fakeHammerInstance);
|
||||||
|
|
||||||
|
plugin = new HammerGesturesPlugin(document, hammerConfig, fakeConsole, loader);
|
||||||
|
|
||||||
|
// Use a fake EventManager that has access to the NgZone.
|
||||||
|
plugin.manager = { getZone: () => ngZone } as EventManager;
|
||||||
|
|
||||||
|
someElement = document.createElement('div');
|
||||||
|
someListener = () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => { (window as any).Hammer = originalHammerGlobal; });
|
||||||
|
|
||||||
|
it('should not log a warning when HammerJS is not loaded', () => {
|
||||||
|
plugin.addEventListener(someElement, 'swipe', () => {});
|
||||||
|
expect(fakeConsole.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should defer registering an event until Hammer is loaded', fakeAsync(() => {
|
||||||
|
plugin.addEventListener(someElement, 'swipe', someListener);
|
||||||
|
expect(fakeHammerInstance.on).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
(window as any).Hammer = {};
|
||||||
|
resolveLoader();
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(fakeHammerInstance.on).toHaveBeenCalledWith('swipe', jasmine.any(Function));
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should cancel registration if an event is removed before being added', fakeAsync(() => {
|
||||||
|
const deregister = plugin.addEventListener(someElement, 'swipe', someListener);
|
||||||
|
deregister();
|
||||||
|
|
||||||
|
(window as any).Hammer = {};
|
||||||
|
resolveLoader();
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(fakeHammerInstance.on).not.toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should remove a listener after Hammer is loaded', fakeAsync(() => {
|
||||||
|
const removeListener = plugin.addEventListener(someElement, 'swipe', someListener);
|
||||||
|
|
||||||
|
(window as any).Hammer = {};
|
||||||
|
resolveLoader();
|
||||||
|
tick();
|
||||||
|
|
||||||
|
removeListener();
|
||||||
|
expect(fakeHammerInstance.off).toHaveBeenCalledWith('swipe', jasmine.any(Function));
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should log a warning when the loader fails', fakeAsync(() => {
|
||||||
|
plugin.addEventListener(someElement, 'swipe', () => {});
|
||||||
|
failLoader();
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(fakeConsole.warn)
|
||||||
|
.toHaveBeenCalledWith(
|
||||||
|
`The "swipe" event cannot be bound because the custom Hammer.JS loader failed.`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should load a warning if the loader resolves and Hammer is not present', fakeAsync(() => {
|
||||||
|
plugin.addEventListener(someElement, 'swipe', () => {});
|
||||||
|
resolveLoader();
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(fakeConsole.warn)
|
||||||
|
.toHaveBeenCalledWith(
|
||||||
|
`The custom HAMMER_LOADER completed, but Hammer.JS is not present.`);
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue