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; } }