@rdkmaster/jigsaw-labs
Version:
Jigsaw, the next generation component set for RDK
668 lines (590 loc) • 25.8 kB
text/typescript
import {
ComponentFactoryResolver,
ComponentRef,
ElementRef,
EmbeddedViewRef,
EventEmitter,
Injectable,
NgZone,
Optional,
Renderer2,
TemplateRef,
Type,
ViewContainerRef,
} from "@angular/core";
import {CommonUtils} from "../core/utils/common-utils";
import {AffixUtils, ElementEventHelper} from "../core/utils/internal-utils";
import {JigsawBlock} from "../component/block/block";
import {IDynamicInstantiatable} from "../component/common";
import {ActivatedRoute, NavigationEnd, Router} from "@angular/router";
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/map';
import {Subscription} from "rxjs/Subscription";
import {JigsawTheme} from "../core/theming/theme";
export enum PopupEffect {
fadeIn, fadeOut, bubbleIn, bubbleOut
}
/**
* 用于控制弹出视图的各种参数,包括是否模态、弹出位置、视图尺寸、弹出动画等等,`PopupService`能够覆盖所有弹出场景,
* 很大程度上得益于这个参数的强大扩展性,熟悉这个参数的各个属性对是否能够用好`PopupService`有着决定性的影响。
*
* [这个demo](/components/dialog/demo#popup-option)详细的说明了如何使用这个对象。
*/
export class PopupOptions {
/**
* 控制弹出的视图是否模态。
*
* @type {boolean} 默认值是false
*/
modal?: boolean;
/**
* 弹出的动效,fadeIn/fadeOut,wipeIn/wipeOut
*
* @type {PopupEffect}
*/
showEffect?: PopupEffect = PopupEffect.fadeIn;
/**
* 隐藏的动效,fadeIn/fadeOut,wipeIn/wipeOut
*
* @type {PopupEffect}
*/
hideEffect?: PopupEffect = PopupEffect.fadeOut;
/**
* 控制弹出对象的相对位置,可以是相对一个点({@link PopupPoint}),也可以相对一个组件或者dom元素,支持如下类型:
* - {@link PopupPoint}类型:控制弹出视图的左上角位置,一般常常用于弹出上下文菜单,通过鼠标的事件(click/hover)等得到鼠标事件,
* 从事件对象中提取出位置信息传过来,从而让视图出现在鼠标的附近,配合`posOffset`属性可以进一步修正位置,避免遮挡到其他重要视图。
* - `ElementRef`和`HTMLElement`类型:相对某个已知UI元素的位置,不配置偏移的话,弹出视图的左上角会和给定的UI元素的左上角位置重合。
* Jigsaw会自动计算出给定元素的位置,并将弹出视图移动到该位置上。一般需要配合`posOffset`属性一起调整弹出位置,
* 避免遮挡到给定的UI元素。这个方式在实现一些下拉功能的时候会非常有用。
*
* 请参考[这个demo]($demo=dialog/popup-option)。
*
* @type {PopupPosition}
*/
pos?: PopupPosition;
/**
* 弹出位置的偏移量,注意left属性是以弹出组件的左侧为基准,top属性是以弹出组件的上方为基准,
* right属性是以弹出组件的右侧为基准,bottom是以弹出组件的下方为基准点。
*
* 请参考[这个demo]($demo=dialog/popup-option)。
*
* @type {PopupPositionOffset}
*/
posOffset?: PopupPositionOffset;
/**
* 弹出的组件的定位方式,和css的 absolute/fixed 含义类似。
*
* 请参考[这个demo]($demo=dialog/popup-option)。
*
* @type {PopupPositionType}
*/
posType?: PopupPositionType;
/**
* 弹出位置修正函数,在PopupService自动计算的位置无法满足需要的时候,可以通过它来修正,在一些需要精确定位的场景非常有用,函数的定义如下:
*
* ```
* (pos: PopupPositionValue, popupElement: HTMLElement) => PopupPositionValue
* ```
*/
posReviser?: (pos: PopupPositionValue, popupElement: HTMLElement) => PopupPositionValue;
/**
* 弹出组件的尺寸,多数组件在定义的时候,认为其尺寸是来自于父级元素,特别是组件的宽度更是如此,
* 这个组件在弹出来的时候,父级元素的宽度和高度都是100%,这会让自己占满整个屏幕,非常难看。
* 在这个场景下就可以通过这个属性设置组件在弹出状态下的尺寸。
* 如果你的组件在实现的时候就给定了尺寸或者最大尺寸,则无需设置这个属性。
*
* @type {PopupSize}
*/
size?: PopupSize;
/**
* 当应用页面的路由改变了的时候,自动销毁弹出的组件。当弹出的视图只归属于某个路由状态的时候,
* 这个功能会很有用,在路由变化之后,你无需手工销毁这些弹出的视图。
*
* 但凡事都有两面性,当你弹出的视图在全局下都有意义时,请注意务必关闭这个功能。
* 这样的场景比如通知类的对话框(alert或者notification),他们弹出后,就需要等待用户关闭,不应该自动关闭。
*
* @type {boolean}
*/
disposeOnRouterChanged?: boolean = true;
/**
* 是否要自动给弹出视图加上边框。默认`PopupService`会检测弹出的视图是否有边框,如果有则不加,如果没有则自动加上边框和阴影。
* 设置了true/false之后,则`PopupService`不再自动检测,而是根据这个属性的值决定是否要还是不加边框&阴影。
*
* @type {boolean}
*/
showBorder?: boolean;
}
export type PopupPosition = PopupPoint | ElementRef | HTMLElement;
export class PopupPositionValue {
left: number;
top: number;
}
export class PopupSize {
width?: string | number;
height?: string | number;
minWidth?: string | number;
}
export class PopupPoint {
x: number;
y: number;
}
export class PopupPositionOffset {
top?: number;
left?: number;
right?: number;
bottom?: number;
}
export enum PopupPositionType {
absolute, fixed
}
export type PopupRef = ComponentRef<IPopupable> | EmbeddedViewRef<any>;
export class ButtonInfo {
[index: string]: any;
label?: string;
clazz?: string = '';
type?: string;
disabled?: boolean;
preSize?: string;
}
export type PopupDisposer = () => void;
export interface IPopupable extends IDynamicInstantiatable {
answer: EventEmitter<ButtonInfo>;
[index: string]: any;
}
export class PopupInfo {
instance: IPopupable;
element: HTMLElement;
dispose: PopupDisposer;
answer: EventEmitter<ButtonInfo>;
}
export class PopupService {
private static _instance: PopupService;
public static get instance(): PopupService {
return PopupService._instance;
}
private _popups: PopupInfo[] = [];
public get popups(): PopupInfo[] {
return this._popups.concat();
}
public elements: HTMLElement[] = [];
/**
* 全局插入点
* @internal
*/
public static _viewContainerRef: ViewContainerRef;
/**
* @internal
*/
public static _renderer: Renderer2;
private _eventHelper: ElementEventHelper = new ElementEventHelper();
private _popupZIndex: number = 1000;
constructor(private _cfr: ComponentFactoryResolver,
private _zone: NgZone,
private _router: Router,
private _activatedRoute: ActivatedRoute) {
PopupService._instance = PopupService._instance || this;
}
private _listenRouterChange(disposer: PopupDisposer): void {
if (!this._router) {
return;
}
const disposerSubscription: Subscription = this._router.events
.filter(event => event instanceof NavigationEnd)
.map(() => this._activatedRoute)
.map(route => {
while (route.firstChild) route = route.firstChild;
return route;
})
.subscribe(() => {
disposerSubscription.unsubscribe();
disposer();
});
}
/**
* 打开弹框
* @param what
* @param options
* @param initData
* @return PopupInfo
*/
public popup(what: Type<IPopupable>, options?: PopupOptions, initData?: any): PopupInfo;
public popup(what: TemplateRef<any>, options?: PopupOptions): PopupInfo;
public popup(what: Type<IPopupable> | TemplateRef<any>, options?: PopupOptions, initData?: any): PopupInfo {
if (!PopupService._viewContainerRef || !PopupService._renderer) {
console.error("please use 'jigsaw-root' element as the root of your root component");
return;
}
let popupInfo: PopupInfo,
popupRef: PopupRef,
element: HTMLElement,
popupDisposer: PopupDisposer,
blockDisposer: PopupDisposer,
disposer: PopupDisposer,
removeWindowListens: PopupDisposer[] = [];
//popup block
blockDisposer = this._popupBlocker(options);
[popupInfo, popupRef] = this._popupFactory(what, options);
element = popupInfo.element;
popupDisposer = popupInfo.dispose;
//set disposer
disposer = () => {
const target = this._popups.find(p => p.element === element);
const index = this._popups.indexOf(target);
if (index >= 0) {
this._popups.splice(index, 1);
}
if (popupDisposer) {
popupDisposer();
}
if (blockDisposer) {
blockDisposer();
}
removeWindowListens.forEach(removeWindowListen => removeWindowListen());
};
//set popup
if (popupRef instanceof ComponentRef) {
popupRef.instance.dispose = disposer;
popupRef.instance.initData = initData;
}
removeWindowListens = this._beforePopup(options, element, disposer);
setTimeout(() => {
this._setPopup(options, element);
// 给弹出设置皮肤
let tagName = element.tagName.toLowerCase();
if (tagName != 'jigsaw-block' && tagName != 'j-block') {
const backgroundColor = JigsawTheme.getPopupBackgroundColor();
if (backgroundColor) {
PopupService._renderer.setStyle(element, 'background', backgroundColor);
}
}
}, 0);
const result = {
instance: popupRef['instance'], element: element, dispose: disposer,
answer: popupRef['instance'] ? popupRef['instance'].answer : undefined
};
this._popups.push(result);
return result;
}
private _popupBlocker(options: PopupOptions): PopupDisposer {
let disposer: PopupDisposer;
let element: HTMLElement;
if (this._isModal(options)) {
const blockOptions: PopupOptions = <PopupOptions>{
modal: true,
showEffect: PopupEffect.fadeIn,
hideEffect: PopupEffect.fadeOut
};
const [blockInfo,] = this._popupFactory(JigsawBlock, blockOptions);
disposer = blockInfo.dispose;
element = blockInfo.element;
this._setStyle(options, element);
this._setPopup(blockOptions, element);
}
return disposer
}
private _beforePopup(options: PopupOptions, element: HTMLElement, disposer: PopupDisposer): PopupDisposer[] {
this._removeAnimation(options, element);
this._setStyle(options, element);
if (!options || !options.hasOwnProperty('disposeOnRouterChanged') || options.disposeOnRouterChanged) {
this._listenRouterChange(disposer);
}
return this._setWindowListener(options, element);
}
private _setStyle(options: PopupOptions, element: HTMLElement): void {
PopupService._renderer.setStyle(element, 'z-index', this._popupZIndex);
PopupService._renderer.setStyle(element, 'visibility', 'hidden');
}
private _removeAnimation(options: PopupOptions, element: HTMLElement) {
const popupEffect = PopupService._getPopupEffect(options);
//删除可能有的动画class,确保能触发动画的animationend事件
if (element.classList.contains(popupEffect.showEffect)) {
PopupService._renderer.removeClass(element, popupEffect.showEffect);
}
if (element.classList.contains(popupEffect.hideEffect)) {
PopupService._renderer.removeClass(element, popupEffect.hideEffect);
}
//弹出EmbeddedViewRef时,防止同一个HtmlElement被反复弹出和销毁,使的弹出时监听到上次销毁时的animationend事件
const removeEventListeners = this._eventHelper.get(element, 'animationend');
if (removeEventListeners instanceof Array) {
removeEventListeners.forEach(removeEventListener => {
removeEventListener();
this._eventHelper.del(element, 'animationend', removeEventListener);
})
}
}
private _popupFactory(what: Type<IPopupable> | TemplateRef<any>, options: PopupOptions): [PopupInfo, PopupRef] {
const ref: PopupRef = this._createPopup(what);
const element: HTMLElement = this._getElement(ref);
//一出来就插入到文档流的最后,这给后续计算尺寸造成麻烦,这里给设置fixed,就可以避免影响滚动条位置
PopupService._renderer.setStyle(element, 'position', 'fixed');
PopupService._renderer.setStyle(element, 'top', 0);
const disposer: PopupDisposer = this._getDisposer(options, ref, element);
return [{element: element, dispose: disposer, answer: null, instance: null}, ref];
}
private _createPopup(what: Type<IPopupable> | TemplateRef<any>) {
if (what instanceof TemplateRef) {
return PopupService._viewContainerRef.createEmbeddedView(what);
} else {
const factory = this._cfr.resolveComponentFactory(what);
return PopupService._viewContainerRef.createComponent(factory);
}
}
private _getElement(ref: PopupRef): HTMLElement {
let element: HTMLElement;
if (ref instanceof ComponentRef) {
element = ref.location.nativeElement.localName == 'ng-component' ?
ref.location.nativeElement.children[0] : ref.location.nativeElement;
} else {
element = ref.rootNodes.find(rootNode => rootNode instanceof HTMLElement);
}
return element
}
private _getDisposer(options: PopupOptions, popupRef: PopupRef, element: HTMLElement): PopupDisposer {
return () => this._setHideAnimate(options, element, () => popupRef.destroy())
}
/**
* 是否模态(popupService只提供全局模态):
* 没配options
* 或options为空对象
* 或者modal为true
* @param options
* @returns {boolean}
*/
private _isModal(options: PopupOptions): boolean {
return CommonUtils.isEmptyObject(options) || options.modal;
}
/**
* 判断弹框是否居中显示:
* 没配options
* 或options为空对象
* 或没有配options.pos
* @param options
* @returns {boolean}
*/
private _isGlobalPopup(options: PopupOptions): boolean {
return CommonUtils.isEmptyObject(options) || !options.pos;
}
private _setPopup(options: PopupOptions, element: HTMLElement) {
if (element) {
this._setSize(options, element);
this.setPosition(options, element);
this._setBackground(options, element);
this._setShowAnimate(options, element);
}
}
private _setWindowListener(options: PopupOptions, element: HTMLElement): PopupDisposer[] {
let removeWindowListens: PopupDisposer[] = [];
if (this._isGlobalPopup(options)) {
this._zone.runOutsideAngular(() => {
// 所有的全局事件应该放到zone外面,不一致会导致removeEvent失效,见#286
removeWindowListens.push(PopupService._renderer.listen('window', 'resize', () => {
PopupService._renderer.setStyle(element, 'top',
(document.body.clientHeight / 2 - element.offsetHeight / 2) + 'px');
PopupService._renderer.setStyle(element, 'left',
(document.body.clientWidth / 2 - element.offsetWidth / 2) + 'px');
}));
});
}
return removeWindowListens;
}
/*
* 设置弹框尺寸
* */
private _setSize(options: PopupOptions, element: HTMLElement) {
if (!options || !options.size) return;
let size = options.size;
if (size.width) {
PopupService._renderer.setStyle(element, 'width', CommonUtils.getCssValue(size.width));
}
if (size.minWidth) {
PopupService._renderer.setStyle(element, 'min-width', CommonUtils.getCssValue(size.minWidth));
}
if (size.height) {
PopupService._renderer.setStyle(element, 'height', CommonUtils.getCssValue(size.height));
}
}
/*
* 设置边框、阴影、动画
* */
private _setBackground(options: PopupOptions, element: HTMLElement) {
if (!this._isModal(options)) {
PopupService._renderer.setStyle(element, 'box-shadow', '1px 1px 6px rgba(0, 0, 0, .2)');
}
if (options && options.showBorder) {
PopupService._renderer.setStyle(element, 'border', '1px solid #dcdcdc');
PopupService._renderer.setStyle(element, 'border-radius', '4px');
}
}
private _setShowAnimate(options: PopupOptions, element: HTMLElement) {
PopupService._renderer.setStyle(element, 'visibility', 'visible');
PopupService._renderer.addClass(element, PopupService._getPopupEffect(options).showEffect);
const removeElementListen = PopupService._renderer.listen(element, 'animationend', () => {
removeElementListen();
this._eventHelper.del(element, 'animationend', removeElementListen);
});
this._eventHelper.put(element, 'animationend', removeElementListen);
}
private _setHideAnimate(options: PopupOptions, element: HTMLElement, cb: () => void) {
PopupService._renderer.addClass(element, PopupService._getPopupEffect(options).hideEffect);
const removeElementListen = PopupService._renderer.listen(element, 'animationend', () => {
removeElementListen();
this._eventHelper.del(element, 'animationend', removeElementListen);
cb();
});
this._eventHelper.put(element, 'animationend', removeElementListen);
}
private static _getPopupEffect(options: PopupOptions) {
let showEffect: string;
let hideEffect: string;
if (options) {
showEffect = PopupService._animateMap.has(options.showEffect) ?
PopupService._animateMap.get(options.showEffect) :
PopupService._animateMap.get(PopupEffect.fadeIn);
hideEffect = PopupService._animateMap.has(options.hideEffect) ?
PopupService._animateMap.get(options.hideEffect) :
PopupService._animateMap.get(PopupEffect.fadeOut);
} else {
showEffect = PopupService._animateMap.get(PopupEffect.fadeIn);
hideEffect = PopupService._animateMap.get(PopupEffect.fadeOut);
}
return {
showEffect: showEffect,
hideEffect: hideEffect
}
}
private static _animateMap = new Map([
[PopupEffect.fadeIn, 'jigsaw-am-fade-in'],
[PopupEffect.fadeOut, 'jigsaw-am-fade-out'],
[PopupEffect.bubbleIn, 'jigsaw-am-bubble-in'],
[PopupEffect.bubbleOut, 'jigsaw-am-bubble-out'],
]);
/*
* 设置弹出的位置
* */
public setPosition(options: PopupOptions, element: HTMLElement): void {
let posType: string = this._isGlobalPopup(options) ? 'fixed' : this._getPositionType(options.posType);
let position = this._getPositionValue(options, element);
PopupService._renderer.setStyle(element, 'position', posType);
PopupService._renderer.setStyle(element, 'top', position.top + 'px');
PopupService._renderer.setStyle(element, 'left', position.left + 'px');
}
/*
* 获取posType字符串类型
* */
private _getPositionType(posType: number): string {
switch (posType) {
case 0:
return 'absolute';
case 1:
return 'fixed';
default:
return 'absolute';
}
}
/**
* 获取位置具体的top和left
*
* options为{}或者null,或者options.pos没配时,默认相对屏幕居中显示
*
*
* */
private _getPositionValue(options: PopupOptions, element: HTMLElement): PopupPositionValue {
let top: number = 0,
left: number = 0;
const popupWidth = element.offsetWidth,
popupHeight = element.offsetHeight;
if (this._isGlobalPopup(options)) {
const documentBody = AffixUtils.getDocumentBody();
top = (documentBody.clientHeight / 2 - popupHeight / 2);
left = (documentBody.clientWidth / 2 - popupWidth / 2);
} else if (options) {
const pos = options.pos,
posOffset = options.posOffset;
if (pos instanceof ElementRef || pos instanceof HTMLElement) {
let posOffsetTop: number,
posOffsetLeft: number;
if (pos instanceof ElementRef) {
posOffsetTop = AffixUtils.offset(pos.nativeElement).top;
posOffsetLeft = AffixUtils.offset(pos.nativeElement).left;
} else if (pos instanceof HTMLElement) {
posOffsetTop = AffixUtils.offset(pos).top;
posOffsetLeft = AffixUtils.offset(pos).left;
}
if (posOffset && typeof posOffset.top == 'number') {
top = (posOffsetTop + posOffset.top);
} else if (posOffset && typeof posOffset.bottom == 'number') {
top = (posOffsetTop - popupHeight + posOffset.bottom);
} else {
top = posOffsetTop;
}
if (posOffset && typeof posOffset.left == 'number') {
left = (posOffsetLeft + posOffset.left);
} else if (posOffset && typeof posOffset.right == 'number') {
left = (posOffsetLeft - popupWidth + posOffset.right);
} else {
left = posOffsetLeft;
}
} else if (pos) {
if (typeof pos.y == 'number') {
if (posOffset && typeof posOffset.top == 'number') {
top = (pos.y + posOffset.top);
} else {
top = pos.y;
}
}
if (typeof pos.x == 'number') {
if (posOffset && typeof posOffset.left == 'number') {
left = (pos.x + posOffset.left);
} else {
left = pos.x;
}
}
}
}
const result = {top: top, left: left};
if (options && options.posReviser) {
const revised = options.posReviser(result, element);
return revised ? revised : result;
} else {
return result;
}
}
/**
* 计算弹窗的位置,默认向下弹出,当下面的位置不足时,改成向上弹
* 默认靠左弹,当右边位置不足时,靠右弹
*/
public positionReviser(pos: PopupPositionValue, popupElement: HTMLElement,
options?: { offsetWidth?: number, offsetHeight?: number, direction?: 'v' | 'h' }): PopupPositionValue {
if (!options || !options.direction || options.direction == 'v') {
// 调整上下位置
const upDelta = (options && options.offsetHeight ? options.offsetHeight : 0) + popupElement.offsetHeight;
if (document.body.clientHeight <= upDelta) {
// 可视区域比弹出的UI高度还小就不要调整了
return pos;
}
const needHeight = pos.top + popupElement.offsetHeight;
const totalHeight = window.pageYOffset + document.body.clientHeight;
if (needHeight >= totalHeight && pos.top > upDelta) {
// 下方位置不够且上方位置足够的时候才做调整
pos.top -= upDelta;
}
}
if (!options || !options.direction || options.direction == 'h') {
// 调整左右位置
const leftDelta = popupElement.offsetWidth - (options && options.offsetWidth ? options.offsetWidth : 0);
if (document.body.clientWidth <= leftDelta && leftDelta <= 0) {
// 可视区域比弹出的UI高度还小就不要调整了
// 弹框宽度比宿主宽度小也不用调整
return pos;
}
const needWidth = pos.left + popupElement.offsetWidth;
const totalWidth = window.pageXOffset + document.body.clientWidth;
if (needWidth >= totalWidth && pos.left > leftDelta) {
// 右边位置不够且左边位置足够的时候才做调整
pos.left -= leftDelta;
}
}
return pos;
}
}