import { EventEmitter } from '../event';
import { throttle as _throttle, noop } from '../functions';
import { IIntersectionObserverExtOption, IIntersectionChangeData } from './types';
const defaultThresholdList = new Array(101).fill(0).map((_, v) => v / 100);
/**
* IntersectionEvent Types
* @event IntersectionObserver#INTERSECTION_EVENTS
* @memberof IntersectionObserver
* @property {String} CHANGE - 변경 시점
*/
// for jsdoc
/**
* @export
* @readonly
* @enum {INTERSECTION_EVENTS}
*/
export const INTERSECTION_EVENTS = {
CHANGE: 'INTERSECTION_OBSERVER-EVENTS-CHANGE'
};
/**
* @typedef {String} INTERSECTION_DIRECTIONS
* @alias INTERSECTION_DIRECTIONS
* @memberof IntersectionObserver
* @property {String} UP
* @property {String} DOWN
* @property {String} UNKNOWN
*/
// for jsdoc
/**
* @export
* @readonly
* @enum {INTERSECTION_DIRECTIONS}
*/
export enum INTERSECTION_DIRECTIONS {
UP = 'INTERSECTION_OBSERVER-DIRECTIONS-UP',
DOWN = 'INTERSECTION_OBSERVER-DIRECTIONS-DOWN',
UNKNOWN = 'INTERSECTION_OBSERVER-DIRECTIONS-UNKNOWN'
};
export class ExtIntersectionObserverEntry {
private options: Partial<IntersectionObserverInit>;
private target: Element;
private visibleRatio: number;
constructor(initParams: Partial<IIntersectionObserverExtOption>) {
const { target, visibleRatio = 0, options = {} } = initParams;
/* istanbul ignore next: for not support error */
if (!target) {
throw new Error('IntersectionObserver target 설정은 필수 값 입니다.');
}
const defParams = {
root: null,
rootMargin: '0px',
threshold: defaultThresholdList
};
this.target = target;
this.visibleRatio = visibleRatio;
this.options = Object.assign({}, defParams, options);
}
private checkForIntersections(target: Element, options: IntersectionObserverInit): IntersectionObserverEntry {
const { root, rootMargin } = options;
const rootMarginValues = this.parseRootMargin(rootMargin);
const rootIsInDom = this.rootIsInDom(root);
const rootRect = rootIsInDom ? this.getRootRect(root, rootMarginValues) : this.getEmptyRect();
const boundingClientRect = this.getBoundingClientRect(target);
const rootContainsTarget = this.rootContainsTarget(root, target);
const intersectionRect = rootIsInDom && rootContainsTarget && this.computeTargetAndRootIntersection(target, rootRect, root);
const isIntersecting = !!intersectionRect;
const targetArea = boundingClientRect.width * boundingClientRect.height;
let intersectionRatio = 0;
if (targetArea) {
const rect = intersectionRect || this.getEmptyRect();
const intersectionArea = rect.width * rect.height
intersectionRatio = Number((intersectionArea / targetArea).toFixed(4));
} else {
intersectionRatio = isIntersecting ? 1 : 0;
}
return {
boundingClientRect,
intersectionRatio,
intersectionRect: intersectionRect || this.getEmptyRect(),
isIntersecting,
rootBounds: rootRect as DOMRectReadOnly | null,
target,
time: window.performance && performance.now && performance.now()
} as IntersectionObserverEntry;
}
private computeTargetAndRootIntersection(target = null, rootRect = null, root = null) {
if (window.getComputedStyle(target).display === 'none') {
return false;
}
const targetRect = this.getBoundingClientRect(target);
let intersectionRect: Partial<DOMRect> = targetRect;
let parent = this.getParentNode(target);
let atRoot = false;
while (!atRoot) {
let parentRect = null;
const parentComputedStyle = (parent.nodeType === 1 ? window.getComputedStyle(parent) : {}) as CSSStyleDeclaration;
if (parentComputedStyle.display === 'none') {
return;
}
if (parent === root) {
atRoot = true;
parentRect = rootRect;
} else {
if (
parent !== document.body &&
parent !== document.documentElement &&
parentComputedStyle.overflow !== 'visible'
) {
parentRect = this.getBoundingClientRect(parent);
}
}
if (parentRect) {
intersectionRect = this.computeRectIntersection(parentRect, intersectionRect)
if (!intersectionRect) {
break;
}
}
parent = this.getParentNode(parent);
}
return intersectionRect;
}
private computeRectIntersection(rect1: Partial<DOMRect>, rect2: Partial<DOMRect>): Partial<DOMRect> {
const top = Math.max(rect1.top, rect2.top);
const bottom = Math.min(rect1.bottom, rect2.bottom);
const left = Math.max(rect1.left, rect2.left);
const right = Math.min(rect1.right, rect2.right);
const width = right - left;
const height = bottom - top;
const values = { top, bottom, left, right, width, height };
return (width >= 0 && height >= 0) && values;
}
private parseRootMargin(rootMargin: string = '0px'): Array<{ value: number, unit: string }> {
const margins = rootMargin.split(/\s+/).map((margin) => {
const parts = /^(-?\d*\.?\d+)(px|%)$/.exec(margin);
if (!parts) {
throw new Error('rootMargin must be specified in pixels or percent');
}
return { value: window.parseFloat(parts[1]), unit: parts[2] };
});
margins[1] = margins[1] || margins[0];
margins[2] = margins[2] || margins[0];
margins[3] = margins[3] || margins[1];
return margins;
}
private rootIsInDom(root: Element | null) {
return root || this.containsDeep(window.document, root);
}
private rootContainsTarget(root: Element | null, target: Element) {
return this.containsDeep(root || window.document, target);
}
private containsDeep(parent: Document | Element, child: Element) {
let node = child;
while (node) {
if (node === parent) {
return true;
}
node = this.getParentNode(node);
}
return false;
}
private getParentNode(node: Element): Element {
const parent = node.parentNode;
if (parent && parent.nodeType === 11 && (parent as ShadowRoot).host) {
// If the parent is a shadow root, return the host element.
return (parent as ShadowRoot).host;
}
return parent as Element;
}
private getRootRect(root: Element | null, rootMargin = []): DOMRect {
let rootRect;
if (root) {
rootRect = this.getBoundingClientRect(root);
} else {
// Use <html>/<body> instead of window since scroll bars affect size.
const html = window.document.documentElement;
const body = window.document.body;
rootRect = {
top: 0,
left: 0,
right: html.clientWidth || body.clientWidth,
width: html.clientWidth || body.clientWidth,
bottom: html.clientHeight || body.clientHeight,
height: html.clientHeight || body.clientHeight
};
}
return this.expandRectByRootMargin(rootRect, rootMargin);
}
private getBoundingClientRect(el: Element): DOMRect | null {
let rect = null;
try {
rect = el.getBoundingClientRect();
} catch (err) {
// Ignore Windows 7 IE11 "Unspecified error"
// https://github.com/w3c/IntersectionObserver/pull/205
}
if (!rect) {
return this.getEmptyRect();
}
return rect;
}
private getEmptyRect(): DOMRect {
const emptyRect = {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
x: 0,
y: 0
}
return {
...emptyRect,
toJSON: () => emptyRect
};
}
private expandRectByRootMargin(rect: DOMRect, rootMargin = []): DOMRect {
const [mt, mr, mb, ml] = rootMargin.map((margin, i) => {
return margin.unit === 'px' ? margin.value : (margin.value * (i % 2 ? rect.width : rect.height)) / 100;
});
const { top, right, bottom, left } = rect;
const newRect = {
top: top - mt,
right: right + mr,
bottom: bottom + mb,
left: left - ml,
x: 0,
y: 0,
width: 0,
height: 0
};
newRect.x = newRect.left;
newRect.y = newRect.top;
newRect.width = newRect.right - newRect.left;
newRect.height = newRect.bottom - newRect.top;
return {
...newRect,
toJSON: () => newRect
};
}
getChangeData(): IIntersectionChangeData {
const entry = this.checkForIntersections(this.target, this.options);
const intersectionRatio = Math.floor(entry.intersectionRatio * 100);
const isVisible = entry.intersectionRatio > this.visibleRatio;
return {
currentTarget: this.target,
entry,
intersectionRatio,
isVisible,
direction: INTERSECTION_DIRECTIONS.UNKNOWN
}
}
}
class PureIntersectionObserver {
private pure: any;
constructor(callback: IntersectionObserverCallback, options: IntersectionObserverInit) {
// TODO polyfill
// https://www.npmjs.com/package/intersection-observer
// const IntersectionObserver = require('intersection-observer');
// this.pure = new IntersectionObserver(callback);
this.pure = new (window as any).IntersectionObserver(callback, options);
}
public observe(target: Element | any, options: IntersectionObserverInit): void {
this.pure.observe(target, options);
}
public disconnect(): void {
this.pure.disconnect();
}
}
/**
* element 가 viewport 영역에 노출되는지 변화를 감지
* @export
* @class IntersectionObserver
* @alias observer/IntersectionObserver
* @extends {EventEmitter}
* @see https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver/IntersectionObserver
* @see https://caniuse.com/#search=IntersectionObserver
* @throws {Error} target 옵션을 지정하지 않은 경우 - throw new Error('IntersectionObserver target 설정은 필수 값 입니다.');
* @param {Partial<IIntersectionObserverExtOption>} initParams
* @param {Element} initParams.target
* @param {Number} [initParams.visibleRatio=0]
* @param {Number} [initParams.throttle=300]
* @param {Function|null} [initParams.callback={@link noop}] callback function
* @param {IntersectionObserverInit} [initParams.options={}]
* @example
.. TODO
*/
export class IntersectionObserver extends EventEmitter {
private observer: PureIntersectionObserver;
private options: Partial<IntersectionObserverInit>;
private target: Element;
private visibleRatio: number;
private prevRatio: number;
constructor(initParams: Partial<IIntersectionObserverExtOption>) {
super();
const { target, visibleRatio = 0, throttle = 300, callback = noop, options = {} } = initParams;
/* istanbul ignore next: for not support error */
if (!target) {
throw new Error('IntersectionObserver target 설정은 필수 값 입니다.');
}
const defParams = {
root: null,
rootMargin: '0px',
threshold: defaultThresholdList
};
this.target = target;
this.visibleRatio = visibleRatio;
this.prevRatio = 0;
this.onIntersectionCallback = callback;
this.onIntersectionListener = this.onIntersectionListener.bind(this);
this.options = Object.assign({}, defParams, options);
if (throttle) {
this.observer = new PureIntersectionObserver(_throttle(this.onIntersectionListener, throttle), this.options);
} else {
this.observer = new PureIntersectionObserver(this.onIntersectionListener, this.options);
}
}
/**
* element 가 이미 노출된 경우 등, IntersectionObserver 등록으로 판단이 어려울 경우<br>
* IntersectionObserver.getIntersectionEntry 를 이용하여 target 의 노출 여부를 판단
* @static
* @param {Partial<IIntersectionObserverExtOption>} initParams
* @param {Element} initParams.target
* @param {Number} [initParams.visibleRatio=0]
* @param {Number} [initParams.throttle=300]
* @param {Function|null} [initParams.callback={@link noop}] callback function
* @param {IntersectionObserverInit} [initParams.options={}]
* @returns {IIntersectionChangeData}
* @memberof IntersectionObserver
* @example
.. TODO
*/
static getIntersectionEntry(initParams: Partial<IIntersectionObserverExtOption>): IIntersectionChangeData {
return new ExtIntersectionObserverEntry(initParams).getChangeData();
}
private onIntersectionCallback = (changeData: IIntersectionChangeData) => {};
/**
* 변화 감지 이벤트 리스너
* @private
* @param {IntersectionObserverEntry[]} entries
* [MDN IntersectionObserverEntry]{@link https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry}
* @memberof IntersectionObserver
* @fires IntersectionObserver#INTERSECTION_EVENTS
*/
private onIntersectionListener(entries: IntersectionObserverEntry[]): void {
const entry: IntersectionObserverEntry = entries[0];
const { intersectionRatio } = entry;
const isVisible = intersectionRatio > this.visibleRatio;
const direction = this.prevRatio > intersectionRatio ? INTERSECTION_DIRECTIONS.DOWN : INTERSECTION_DIRECTIONS.UP
const changeData: IIntersectionChangeData = {
currentTarget: this.target,
entry,
intersectionRatio: Math.floor(intersectionRatio * 100),
isVisible,
direction
};
this.emit(INTERSECTION_EVENTS.CHANGE, changeData);
this.onIntersectionCallback(changeData);
this.prevRatio = intersectionRatio;
}
/**
* 이벤트 감지 설정
* @returns {IntersectionObserver}
* @memberof IntersectionObserver
*/
public attach(): IntersectionObserver {
this.observer.observe(this.target, this.options);
return this;
}
/**
* 이벤트 감지 해제
* @returns {IntersectionObserver}
* @memberof IntersectionObserver
*/
public detach(): IntersectionObserver {
this.observer.disconnect();
return this;
}
/**
* destroy
* @returns {IntersectionObserver}
* @memberof IntersectionObserver
*/
public destroy(): IntersectionObserver {
this.detach();
this.observer = null;
return this;
}
}